Skip to content

Commit

Permalink
template: add local auth and email sending (#484)
Browse files Browse the repository at this point in the history
- Add Individual user accounts (local auth) as a template option
- Add Azure ACS as an email option
- Add SendGrid as an email option
- Add `reset` method to API callers
- Switch external logins in template to use ExternalLogin razor page instead of OnTicketReceived hooks
  • Loading branch information
ascott18 authored Oct 23, 2024
1 parent 74431fd commit a99f872
Show file tree
Hide file tree
Showing 54 changed files with 1,973 additions and 530 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ jobs:
- "--Identity --MicrosoftAuth --AuditLogs"
# Tenancy variants:
- "--Identity --Tenancy --TenantCreateExternal --GoogleAuth"
- "--Identity --Tenancy --TenantCreateSelf --TenantMemberInvites --AuditLogs" # todo: add local accounts to this case when we add it
- "--Identity --Tenancy --TenantCreateAdmin --TenantMemberInvites --MicrosoftAuth" # todo: add local accounts to this case when we add it
- "--Identity --Tenancy --TenantCreateSelf --TenantMemberInvites --AuditLogs --LocalAuth --EmailSendGrid"
- "--Identity --Tenancy --TenantCreateAdmin --TenantMemberInvites --MicrosoftAuth --LocalAuth --EmailAzure"


defaults:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 5.2.0

- feat: Added a `reset` method to all API caller objects. This method resets all stateful fields on the object to default values.

# 5.1.0

- feat: All Coalesce-generated endpoints that accept a formdata body now also accept a JSON body. Existing formdata endpoints remain unchanged. `coalesce-vue` does not yet use these new endpoints.
Expand Down
2 changes: 1 addition & 1 deletion docs/stacks/vue/TemplateBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const selections = ref([
"DarkMode",
"AuditLogs",
"UserPictures",
"ExampleModel",
// "ExampleModel",
]);
watch(
Expand Down
49 changes: 49 additions & 0 deletions src/coalesce-vue/src/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,20 @@ export abstract class ApiState<
return this.__rawResponse.value;
}

/** Reset all state fields of the instance.
* Does not reset configuration like response caching and concurrency mode.
*/
public reset() {
if (this.isLoading)
throw new Error("Cannot reset while a request is pending.");
this.hasResult = false;
this.result = null;
this.wasSuccessful = null;
this.message = null;
this.isLoading = false;
this.__rawResponse.value = undefined;
}

private __responseCacheConfig?: ResponseCachingConfiguration;
/**
* Enable response caching for the API caller,
Expand Down Expand Up @@ -1768,6 +1782,18 @@ export class ItemApiState<TArgs extends any[], TResult> extends ApiState<
super(apiClient, invoker);
}

public override reset(): void {
super.reset();
this.result = null;
this.validationIssues = null;
if (this._objectUrl?.url) {
URL.revokeObjectURL(this._objectUrl.url);
this._objectUrl.url = undefined;
this._objectUrl.target = undefined;
this._objectUrl = undefined;
}
}

private _objectUrl?: {
url?: string;
target?: TResult;
Expand Down Expand Up @@ -1857,6 +1883,13 @@ export class ItemApiStateWithArgs<
this.__args.value = v;
}

public override reset(resetArgs = true) {
super.reset();
if (resetArgs) {
this.resetArgs();
}
}

/** Invokes a call to this API endpoint.
* If `args` is not provided, the values in `this.args` will be used for the method's parameters. */
public invokeWithArgs(args: TArgsObj = this.args): Promise<TResult> {
Expand Down Expand Up @@ -1991,6 +2024,15 @@ export class ListApiState<TArgs extends any[], TResult> extends ApiState<
super(apiClient, invoker);
}

override reset() {
super.reset();
this.result = null;
this.totalCount = null;
this.pageCount = null;
this.pageSize = null;
this.page = null;
}

protected setResponseProps(data: ListResult<TResult>) {
this.wasSuccessful = data.wasSuccessful;
this.message = data.message || null;
Expand Down Expand Up @@ -2023,6 +2065,13 @@ export class ListApiStateWithArgs<
this.__args.value = v;
}

public override reset(resetArgs = true) {
super.reset();
if (resetArgs) {
this.resetArgs();
}
}

/** Invokes a call to this API endpoint.
* If `args` is not provided, the values in `this.args` will be used for the method's parameters. */
public invokeWithArgs(args: TArgsObj = this.args): Promise<TResult> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@
"$coalesceRequires": ["and", "Identity"],
"$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/google-logins"
},
"LocalAuth": {
"type": "parameter",
"datatype": "bool",
"displayName": "Sign-in with Username/Password",
"description": "Adds infrastructure for supporting individual user accounts with first-party usernames and passwords.",
"$coalesceRequires": ["and", "Identity"],
"$coalesceLink": "https://learn.microsoft.com/en-us/aspnet/core/security/authentication/individual"
},
"UserPictures": {
"type": "parameter",
"datatype": "bool",
Expand Down Expand Up @@ -136,6 +144,20 @@
"description": "Include configuration and integrations for Application Insights, both front-end and back-end.",
"$coalesceLink": "https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview"
},
"EmailAzure": {
"type": "parameter",
"datatype": "bool",
"displayName": "Email: Azure Communication Services",
"description": "Include basic code for sending email with Azure Communication Services. See instructions in appsettings.json - the official ACS documentation is very confusing.",
"$coalesceLink": "https://learn.microsoft.com/en-us/azure/communication-services/concepts/email/prepare-email-communication-resource"
},
"EmailSendGrid": {
"type": "parameter",
"datatype": "bool",
"displayName": "Email: Twilio SendGrid",
"description": "Include basic code for sending email with Twilio SendGrid.",
"$coalesceLink": "https://www.twilio.com/docs/sendgrid/for-developers/sending-email/email-api-quickstart-for-c"
},
"AzurePipelines": {
"type": "parameter",
"datatype": "bool",
Expand Down Expand Up @@ -170,7 +192,7 @@
{
"condition": "!Identity",
"exclude": [
"**/AuthenticationConfiguration.cs",
"**/ProgramAuthConfiguration.cs",
"**/Forbidden.vue",
"**/UserAvatar.vue",
"**/UserProfile.vue",
Expand All @@ -186,7 +208,17 @@
},
{
"condition": "!MicrosoftAuth && !GoogleAuth",
"exclude": ["**/SignInService.cs"]
"exclude": ["**/ExternalLogin.*"]
},
{
"condition": "!LocalAuth",
"exclude": [
"**/ResetPassword.*",
"**/Register.*",
"**/ForgotPassword.*",
"**/ConfirmEmail.*",
"**/UserManagementService.cs"
]
},
{
"condition": "!Tenancy",
Expand Down Expand Up @@ -235,6 +267,14 @@
"condition": "!MicrosoftAuth",
"exclude": ["**/microsoft-logo.svg"]
},
{
"condition": "!EmailAzure",
"exclude": ["**/AzureEmailOptions.cs", "**/AzureEmailService.cs"]
},
{
"condition": "!EmailSendGrid",
"exclude": ["**/SendGridEmailOptions.cs", "**/SendGridEmailService.cs"]
},
{
"condition": "!ExampleModel",
"exclude": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#endif
#if Identity
.Format<User>(x => x.PasswordHash, x => "<password changed>")
.Format<User>(x => x.SecurityStamp, x => "<stamp changed>")
.ExcludeProperty<User>(x => new { x.ConcurrencyStamp })
#endif
#if Tenancy
.ExcludeProperty<ITenanted>(x => new { x.TenantId })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using Microsoft.AspNetCore.DataProtection;
using Coalesce.Starter.Vue.Data.Communication;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using System.Text.Encodings.Web;
using System.Text.Json;

namespace Coalesce.Starter.Vue.Data.Auth;

public class InvitationService(
AppDbContext db,
IDataProtectionProvider dataProtector,
IUrlHelper urlHelper
IUrlHelper urlHelper,
IEmailService emailService
)
{
private IDataProtector GetProtector() => dataProtector.CreateProtector("invitations");
Expand All @@ -19,8 +22,9 @@ Role[] roles
{
var tenantId = db.TenantIdOrThrow;

if (roles.Any(r => r.TenantId != tenantId)) return "Role/tenant mismatch";
if (roles.Any(r => db.Roles.FirstOrDefault(dbRole => dbRole.Id == r.Id) is null)) return "One or more roles are invalid";

var tenant = db.Tenants.Find(tenantId)!;
var invitation = new UserInvitation
{
Email = email,
Expand All @@ -41,10 +45,11 @@ Role[] roles

var link = CreateInvitationLink(invitation);

// TODO: Implement email sending and send the invitation link directly to `email`.
// Returning it directly in the result message is a temporary measure.

return new(true, message: $"Give the following invitation link to {email}:\n\n{link}");
return await emailService.SendEmailAsync(email, $"Invitation to {tenant.Name}",
$"""
You have been invited to join the <b>{HtmlEncoder.Default.Encode(tenant.Name)}</b> organization.
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to accept the invitation.
""");
}

public async Task<ItemResult> AcceptInvitation(
Expand All @@ -55,14 +60,24 @@ User acceptingUser
var tenant = await db.Tenants.FindAsync(invitation.TenantId);
if (tenant is null) return "Tenant not found";

if (!invitation.Email.Equals(acceptingUser.Email, StringComparison.OrdinalIgnoreCase))
{
return $"Your email address doesn't match the intended recipient of this invitation.";
}

// Note: `acceptingUser` will be untracked after ForceSetTenant.
db.ForceSetTenant(invitation.TenantId);
acceptingUser = db.Users.Find(acceptingUser.Id)!;

if (await db.TenantMemberships.AnyAsync(m => m.User == acceptingUser))
{
return $"{acceptingUser.UserName ?? acceptingUser.Email} is already a member of {tenant.Name}.";
}

// Since invitations are emailed to users, they also act as an email confirmation.
// If this is not true for your application, delete this line.
acceptingUser.EmailConfirmed = true;

db.TenantMemberships.Add(new() { UserId = acceptingUser.Id });
db.UserRoles.AddRange(invitation.Roles.Select(rid => new UserRole { RoleId = rid, UserId = acceptingUser.Id }));
await db.SaveChangesAsync();
Expand All @@ -75,7 +90,7 @@ public string CreateInvitationLink(UserInvitation invitation)
var inviteJson = JsonSerializer.Serialize(invitation);
var inviteCode = GetProtector().Protect(inviteJson);

return urlHelper.PageLink("/invitation", values: new { code = inviteCode })!;
return urlHelper.PageLink("/Invitation", values: new { code = inviteCode })!;
}

public ItemResult<UserInvitation> DecodeInvitation(string code)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using Coalesce.Starter.Vue.Data.Communication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using System.Net.Mail;
using System.Text.Encodings.Web;

namespace Coalesce.Starter.Vue.Data.Auth;

public class UserManagementService(
UserManager<User> _userManager,
IUrlHelper urlHelper,
IEmailService emailSender
)
{
public async Task<ItemResult> SendEmailConfirmationRequest(User user)
{
if (user.EmailConfirmed) return "Email is already confirmed.";
if (string.IsNullOrWhiteSpace(user.Email)) return "User has no email";

var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);

var link = urlHelper.PageLink("/ConfirmEmail", values: new { userId = user.Id, code = code })!;

var result = await emailSender.SendEmailAsync(
user.Email,
"Confirm your email",
$"""
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to confirm your account.
If you didn't request this, ignore this email and do not click the link.
"""
);

if (result.WasSuccessful)
{
result.Message += " Please click the link in the email to confirm your account.";
}

return result;
}

public async Task<ItemResult> SendEmailChangeRequest(User user, string newEmail)
{
// This is secured by virtue of the filtering done in the [DefaultDataSource].
// Regular users can only fetch themselves out of the data source,
// admins can only view users in their own tenant,
// and tenant admins can view everyone.

if (string.IsNullOrEmpty(newEmail) || !MailAddress.TryCreate(newEmail, out _))
{
return "New email is not valid.";
}

if (string.Equals(user.Email, newEmail, StringComparison.OrdinalIgnoreCase))
{
return "New email is not different.";
}

var code = await _userManager.GenerateChangeEmailTokenAsync(user, newEmail);

var link = urlHelper.PageLink("/ConfirmEmail", values: new { userId = user.Id, code = code, newEmail = newEmail })!;

var result = await emailSender.SendEmailAsync(
newEmail,
"Confirm your email",
$"""
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to complete your email change request.
If you didn't request this, ignore this email and do not click the link.
"""
);

if (result.WasSuccessful)
{
result.Message += " Please click the link in the email to complete the change.";
}

return result;
}

public async Task<ItemResult> SendPasswordResetRequest(User? user)
{
if (user?.Email is not null)
{
var code = await _userManager.GeneratePasswordResetTokenAsync(user);

var link = urlHelper.PageLink("ResetPassword", values: new { userId = user.Id, code = code })!;

await emailSender.SendEmailAsync(
user.Email,
"Password Reset",
$"""
Please <a href="{HtmlEncoder.Default.Encode(link)}">click here</a> to reset your password.
If you didn't request this, ignore this email and do not click the link.
"""
);
}

return new ItemResult(true,
"If the user account exists, the email address on the account " +
"will receive an email shortly with password reset instructions.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,11 @@
<!--#if (AuditLogs) -->
<PackageReference Include="IntelliTect.Coalesce.AuditLogging" Version="$(CoalesceVersion)" />
<!--#endif -->
<!--#if (EmailAzure) -->
<PackageReference Include="Azure.Communication.Email" Version="1.0.1" />
<!--#endif -->
<!--#if (EmailSendGrid) -->
<PackageReference Include="SendGrid" Version="9.29.3" />
<!--#endif -->
</ItemGroup>
</Project>
Loading

0 comments on commit a99f872

Please sign in to comment.