Vincenzo Gibilmanno.

Vincenzo Gibilmanno

Azure Function

2022-07-29

Mit Azure Functions eine einfache Benutzerregistrierung realisieren

Mit Microsoft Azure Functions lassen sich sehr einfach workflows bauen und abbilden, wenn man mit dem Vorgehen vertraut ist. Wir haben damit für uns intern eine einfache Benutzerregistrierung realisiert und wollen euch einen Einblick geben, wie ihr das umsetzen könnt.

Um die Registrierung schlank halten zu können, haben wir auf den Einsatz von zu mächtigen Tools wie Identity Server verzichtet, da diese weitaus mehr können als das, was wir benötigen.

Aus Benutzersicht startet die Registrierung über einen Eingabedialog in den ein Benutzer Name und E-Mail eingibt. Mit dem Absenden der Daten bekommt der Benutzer einen Bestätigungslink per E-Mail gesendet, der bestätigt werden muss, um die Registrierung abzuschließen.

Notwendige Azure Functions implementieren

Auf Azure benötigen wir einmal eine Tabelle im Table Storage, um Daten zu persistieren, und jeweils eine Function um einen Benutzer zu registieren, um den Benutzer zu aktivieren und um einen Login zu validieren.

Ich beginne also mit meiner Azure Subscription und lege zunächst eine Nutzer Tabelle im Table Storage an, die alle wichtigen Nutzerdaten hält.

Bei der Registrierungsfunction erzeugt die Azure Function einen Tabelleneintrag des Benutzers mit einem eindeutigen Token. Dieser Token wird an den Aktivierungslink angehängt und gemeinsam per E-Mail an den Nutzer geschickt. Damit erfolgt bei der Aktivierung die Zuordnung zu unserem Tabelleneintrag.

async Task<(bool success, string message)> RegisterUserAsync(UserRegistrationDto userRegistrationDto, ExecutionContext context)
{
    var activationToken = Guid.NewGuid().ToString();
    var salt = Guid.NewGuid().ToString();
    using var sha256Hash = SHA256.Create();
    var hash = UserValidator.GetHash(sha256Hash, "SALTED PASSWORD TEMPLATE"); // Einen eigenen Salt-Template definieren

    var tableClient = this.tableServiceClientWrapper.GetTableClient();
    var user = new UserEntity
    {
        Name = userRegistrationDto.Name,
        Surname = userRegistrationDto.Surname,
        Company = userRegistrationDto.Company,
        Email = userRegistrationDto.Email,
        Password = hash,
        Salt = salt,
        ActivationToken = activationToken,
        TokenExpiration = DateTime.UtcNow.AddDays(1),
        RowKey = Guid.NewGuid().ToString(),
        PartitionKey = Guid.NewGuid().ToString(),
        ETag = new ETag(Guid.NewGuid().ToString())
    };

    var queryResult = tableClient.Query<UserEntity>(x => x.Email.Equals(userRegistrationDto.Email));
    var existingUserEntity = queryResult.FirstOrDefault();
    if (existingUserEntity == null)
    {
        try
        {
            var apiKey = Environment.GetEnvironmentVariable("FunctionApiKey");
            await emailSender.SendActivationTokenMailAsync(userRegistrationDto.Email, $"https://url/api/ActivateTokenFunction?token={activationToken}&code={apiKey}", context);
        }
        catch (Exception ex)
        {
            logger.LogCritical(ex.Message);
            return (false, ex.Message);
        }

        await tableClient.AddEntityAsync<UserEntity>(user);
        return (true, string.Empty); ;
    }
    else
    {
        var message = "User already exists";
        logger.LogInformation(message);
        return (false, message);
    }
}

Die zweite Function empfängt die Aktivierungsnachricht des Nutzers und prüft, ob der Benutzer in der Tabelle existiert und aktiviert diesen.

async Task<(bool success, string message)> ActivateUserAsync(string token)
{
    var tableClient = this.tableServiceClientWrapper.GetTableClient();
    var queryResult = tableClient.Query<UserEntity>(x => x.ActivationToken.Equals(token));
    var existingUserEntity = queryResult.FirstOrDefault();
    if (existingUserEntity == null)
    {
        var message = "Token is invalid";
        logger.LogInformation(message);
        return (false, message);
    }
    else
    {
        if (existingUserEntity.TokenExpiration < DateTime.UtcNow)
        {
            var message = "Token expired";
            logger.LogInformation(message);
            return (false, message);
        }

        existingUserEntity.IsActivated = true;
        await tableClient.UpdateEntityAsync<UserEntity>(existingUserEntity, existingUserEntity.ETag);
        return (true, string.Empty);
    }
}

Die dritte Function prüft, ob ein Nutzer existiert, aktiviert ist und das Passwort stimmt. Hier handelt es sich um eine einfach gehaltene Nutzerverwaltung die wir exemplarisch verwenden. Aus Sicherheitsgründen empfehlen wir einen Flow wie es bei OAuth2 oder bei OIDC empfohlen wird.

async Task<(bool success, string message)> ValidateLoginData(string mail, string password)
{
    var tableClient = this.tableServiceClientWrapper.GetTableClient();
    var queryResult = tableClient.Query<UserEntity>(x => x.Email.Equals(mail));
    var existingUserEntity = queryResult.FirstOrDefault();
    if (existingUserEntity == null)
    {
        var message = "Email does not exist. Register user first";
        logger.LogInformation(message);
        return (false, message);
    }
    else
    {
        if (!existingUserEntity.IsActivated)
        {
            return (false, "Activate user first");
        }

        using var sha256Hash = SHA256.Create();
        var isPasswordValid = VerifyHash(sha256Hash, "SALTED PASSWORD TEMPLATE", existingUserEntity.Password);
        return (isPasswordValid, "Wrong password");
    }
}

Hier noch einmal eine Übersicht wie der einfache Flow aussieht:

E-Mails versenden

Nachdem der Flow implementiert wurde, müssen noch E-Mails versendet werden.

Die ersten Recherchen verweisen auf Sendgrid. Das ist grundsätzlich gut, aber wir wollten uns ein weiteres System sparen. Außerdem können in der kostenlosen Variante aktuell nur 100 E-Mails pro Tag versendet werden. Weitere Recherchen empfehlen die Variante über SMTP, welche allerdings von Azure Function nicht direkt unterstützt wird. Hierfür wäre ein SMTP Relay nötig gewesen, was das Handling kompliziert gemacht hätte. Außerdem wollen wir eine shared Mailbox verwenden. Um den SMTP-Zugriff funktionsfähig zu bekommen, hätten noch weitere, teils aufwendige Anpassungen vorgenommen werden müssen.

Schlussendlich sind wir auf die Microsoft Graph API gekommen, die perfekt dafür geeignet ist und eine einfache Lösung zur schnellen Realisierung bietet. Die Handhabung hat uns so gut gefallen, dass wir uns für diese Lösungen entschieden haben.

Voraussetzungen für das Versenden von E-Mails

Damit das Versenden von E-Mails über die Graph API möglich ist, benötigt man einen Office365 Account. Es muss außerdem eine shared Mailbox bzw. ein Nutzer existieren, dem eine entsprechende Lizenz zugewiesen wurde, E-Mails versenden zu können.

Das Setup

Der erste Schritt besteht darin, eine App in der “App Registration” auf Azure anzulegen.

In den API Permissions muss die App Registration die Berechtigung haben, E-Mails senden zu können.

Abschließend muss unter “Certificates & Secrets” ein Client Secret erstellt werden. Dieses Secret wird im folgenden Codeabschnitt benötigt.

Der folgende Code zeigt, wie über die Microsoft Graph API im Namen der shared Mailbox E-Mails versendet werden können.

async Task SendMailAsync(string email, string activationLink)
{
    var htmlContent = File.ReadAllText("activationLinkEmailTemplate.html");
    htmlContent = htmlContent.Replace("ACTIVATIONLINK_PLACEHOLDER", activationLink, StringComparison.OrdinalIgnoreCase);

    var scopes = new[] { "https://graph.microsoft.com/.default" };

    // Diese Informationen sind in der Overview der App Registration zu finden
    var tenantId = "";
    var clientId = "";

    // Der Client Secret, der zuvor in der App Registration erstellt wurde
    var clientSecret = "";

    var options = new TokenCredentialOptions
    {
        AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
    };

    var clientSecretCredential = new ClientSecretCredential(
        tenantId, clientId, clientSecret, options);

    var graphClient = new GraphServiceClient(clientSecretCredential, scopes);
    var message = new Message
    {
        Subject = "Bitte E-Mail-Adresse bestätigen",
        Body = new ItemBody
        {
            ContentType = BodyType.Html,
            Content = htmlContent
        },
        HasAttachments = true,
        Attachments = new MessageAttachmentsCollectionPage()
        {
            new FileAttachment
            {
                Name = "logo.png",
                ContentType = "image/png",
                ContentBytes = File.ReadAllBytes("logo.png")
            }
        },
        ToRecipients = new List<Recipient>()
        {
            new Recipient
            {
                EmailAddress = new EmailAddress
                {
                    Address = email
                }
            }
        },
        From = new Recipient
        {
            EmailAddress = new EmailAddress
            {
                Address = "[email protected]"
            }
        }
    };

    await graphClient.Users["[email protected]"]
        .SendMail(message, null)
        .Request()
        .PostAsync();
}

Die gesamte Lösung haben wir auf Github hier veröffentlicht.

Fazit

Es ist möglich, ohne großen Aufwand und Drittanbieter wie Sendgrid, On-Premise Server usw. E-Mails zu versenden. Außerdem wird hier wieder verdeutlicht, wie viel mit Azure Functions umgesetzt werden kann und wie einfach das möglich ist. Solange keine CPU/GPU und RAM hungrigen Aufgaben erledigt werden müssen, ist Azure Functions für uns immer die erste Wahl, vor allem wenn man das Preis-Leistungs-Verhältnis betrachtet.