Developer forum

Forum » Development » Implement a DW10 frontend MFA Extranet login flow

Implement a DW10 frontend MFA Extranet login flow

Sebastian Andersen
Reply

Are there any examples or guides on how to implement a frontend MFA extranet login flow?

Currently when we try to log into extranet with a user that has authentication method set to MFA, then we get the error "Login failed for user." on the frontend. We confirmed with Dynamicweb Care that this happens because there is no default MFA login flow for the extranet app on the frontend, and we need to implement that ourselves.

This is on the newest ring 1 release of Dynamicweb 10.22.5.


Replies

 
Nicolai Pedersen Dynamicweb Employee
Nicolai Pedersen
Reply

Hi

The curent Swift 2 implementation does not implement MFA/OTP yet.

Be aware that we have the "old" login module (Known as the Extranet module) and the new one called "Users - Authentication" which is documented here:

https://doc.dynamicweb.dev/documentation/implementing/content/apps/users/users-login.html

Only the new one supports MFA/OTP/Magic links

I am just updating the documentation article - below is the new version that is currently in review so it might contain mistakes.

Users - Authentication

The Users – Authentication app renders a frontend authentication UI and handles sign-in attempts (including MFA/OTP flows and external providers (SSO)). Your job as an implementer is mostly to:

  1. Render the right inputs for the currently active login mode.
  2. Post the expected field names back to the platform.
  3. React to Model.Result to show errors / next-step UI.

Paragraph app settings

Users login

From the paragraph app you have the following settings as an editor:

  • Login template: choose a Razor template from /Templates/Users/UserAuthentication/Login.
  • Redirect after authentication
    • Redirect to specific page: choose a fallback page to send users to after a successful login.
    • Redirect back to referrer: if enabled, a successful login redirects to the page the user came from (when available).
  • Pages
    • Page to set password
    • Page to create user

[!NOTE]

  • If both Redirect back to referrer is enabled and a valid referrer exists, it wins; otherwise the module falls back to Redirect to specific page.
  • The old ForgotPasswordLink editor value is obsolete. Use the Page to set password setting instead.

Mental model: one module, several login “tracks”

The module renders a Razor template with a UserAuthenticationViewModel. That model tells you:

  • Which login type is active (Model.LoginType)
  • What happened last postback (Model.Result)
  • Where to send users after successful login (Model.RedirectAfterLogin)
  • Which extra actions exist (create user / set password links, external providers)

This is created server-side in the module and handed to your template.

Supported login methods (frontend)

Dynamicweb 10’s login pipeline supports these frontend experiences:

  • Username + password (“classic login”)
  • OTP (One-time code): username + emailed code (no password)
  • Magic link: username + emailed link (no password)
  • MFA: username + password, then a verification step (code or link)
  • External authentication: e.g. Azure AD / Google etc. (configured providers)

The server decides which one is relevant via the LoginType (globally and/or per user).

Choosing a login method (how the platform decides)

The platform resolves LoginType like this:

  • It can be set globally (system setting), and
  • it can be overridden per user (if the user has a specific login type configured)

That resolution is done in the authentication manager and surfaced to the template through Model.LoginType.

Practical guidance

  • Use Username/Password when you want a familiar login and don’t require step-up security.
  • Use MFA when you want password + second factor (best for employees, admins, high-risk users).
  • Use OTP / Magic link when you want “passwordless” (best for B2B buyers who hate passwords, or when you want to reduce credential stuffing risk).
  • Use External authentication when identity is owned elsewhere (Azure AD, Google Workspace, etc.) or when you want SSO.

The form contract: field names the backend actually listens for

The login middleware looks for specific form keys to decide what to do.

Template location: /Templates/Users/UserAuthentication/Login. Place a .cshtml file in /Templates/Users/UserAuthentication/Login.
The template should inherit ViewModelTemplate<UserAuthenticationViewModel> and import the Dynamicweb.Users.Frontend.UserAuthentication namespace.

Common fields

  • redirect (hidden): return URL after success (use Model.RedirectAfterLogin)
  • Autologin (checkbox): “keep me signed in” (persistent cookie)
  • username
  • password (only for password-based flows)
  • shopid (optional; only relevant if you use shop separation)

Special fields that trigger specific flows

Start passwordless (OTP / Magic link) without a password

  • Include username
  • Include dologin (any value)
    Because the middleware only allows “username without password” when dologin exists.

Verify MFA/OTP code

  • Include dologin
  • Include code (the user-entered code)

This calls MFA verification on the backend.

External provider login

  • Post DwExternalLoginProvider = provider id
    This starts the external challenge.

External provider callback

  • Query string contains DwExtranetExternalLogin=true
    The middleware finalizes the external sign-in.

Magic link callback

  • Query string contains DwExtranetMagicLink=<encrypted code>
    The middleware verifies and signs the user in.

Handling outcomes: UserAuthenticationResultType

Your template should treat Model.Result as the canonical “what just happened?” flag.

Notable values (not exhaustive):

  • Success
  • IncorrectLogin
  • PasswordExpired
  • ExceededFailedLogOnLimitLoginLocked
  • External: ExternalProviderAuthenticationFailedExternalProviderEmailNotProvided, …
  • MFA: MfaVerificationRequiredMfaVerificationFailed

A key nuance: “MfaVerificationRequired” is not an error. It means: Step 1 succeeded enough that we’re now waiting for verification.

Implementation pattern: one template that adapts

You can implement this as either:

  • Separate templates per login mode (simple, but duplicated markup), or
  • One template that branches on Model.LoginType and Model.Result (usually best)

Below is a single-template pattern that supports all methods.

The example assumes you already have Bootstrap-ish CSS (adjust freely).

Full example: username/password + OTP + MFA + external providers

@inherits ViewModelTemplate<Dynamicweb.Users.Frontend.UserAuthentication.UserAuthenticationViewModel>
@using Dynamicweb.Users.Frontend.UserAuthentication
@using Dynamicweb.Rendering
@using Dynamicweb

@{
    // Handy flags
    bool requiresVerification =
        Model.Result == UserAuthenticationResultType.MfaVerificationRequired
        || Model.Result == UserAuthenticationResultType.MfaVerificationFailed;

    // “Passwordless” login types (server will email code or magic link after the first POST)
    bool isPasswordless =
        Model.LoginType.ToString().Equals("TOTP", StringComparison.OrdinalIgnoreCase)
        || Model.LoginType.ToString().Equals("MagicLinks", StringComparison.OrdinalIgnoreCase);

    bool isMfa =
        Model.LoginType.ToString().Equals("MFA", StringComparison.OrdinalIgnoreCase);

    string? redirect = Model.RedirectAfterLogin;
}

<div class="login">

  @* Result banner *@
  @if (Model.Result != UserAuthenticationResultType.None
      && Model.Result != UserAuthenticationResultType.MfaVerificationRequired) {

      var isSuccess =
          Model.Result == UserAuthenticationResultType.Success
          || Model.Result == UserAuthenticationResultType.PasswordChangedSuccess;

      <div class="alert alert-@(isSuccess ? "success" : "danger")" role="alert">
          @Translate(Model.Result.ToString())
      </div>
  }

  @* Step 2: verification (code entry) *@
  @if (requiresVerification) {

      <h2>@Translate("Verify sign-in")</h2>

      @if (Model.MfaCodeExpiration.HasValue) {
          <p class="text-muted">
              @Translate("Enter the code we sent you. It expires at:")
              <strong>@Model.MfaCodeExpiration.Value.ToString("HH:mm:ss")</strong>
          </p>
      }

      <form method="post">
          <input type="hidden" name="redirect" value="@redirect" />
          <input type="hidden" name="dologin" value="1" />

          <label for="code">@Translate("Code")</label>
          <input id="code" name="code" autocomplete="one-time-code" required />

          <div class="mt-3">
              <button type="submit" class="btn btn-primary">
                  @Translate("Verify")
              </button>
          </div>
      </form>

      @* Optional: a “start over” link could just reload the page *@
  }
  else {

      @* Step 1: primary login *@
      <h2>@Translate("Sign in")</h2>

      <form method="post">
          <input type="hidden" name="redirect" value="@redirect" />

          <label for="username">@Translate("Email")</label>
          <input id="username" name="username" type="email" autocomplete="username" required />

          @* Password is required for classic login + MFA (first step) *@
          @if (!isPasswordless) {
              <label for="password">@Translate("Password")</label>
              <input id="password" name="password" type="password" autocomplete="current-password" required />
          }
          else {
              @* This is the critical “passwordless trigger” *@
              <input type="hidden" name="dologin" value="1" />
              <p class="text-muted">
                  @Translate("We’ll email you a code or a sign-in link.")
              </p>
          }

          <div class="mt-2">
              <input type="checkbox" value="True" name="Autologin" id="remember-me" />
              <label for="remember-me">@Translate("Keep me signed in")</label>
          </div>

          <div class="mt-3">
              <button type="submit" class="btn btn-primary">
                  @(isPasswordless ? Translate("Send me a code") : Translate("Sign in"))
              </button>
          </div>

          <div class="mt-3">
              @if (!string.IsNullOrEmpty(Model.CreatePasswordLink)) {
                  <a href="@Model.CreatePasswordLink">@Translate("Forgot / set password")</a>
              }
              @if (!string.IsNullOrEmpty(Model.CreateUserLink)) {
                  <span> · </span>
                  <a href="@Model.CreateUserLink">@Translate("Create account")</a>
              }
          </div>
      </form>

      @* External providers *@
      @if (Model.ExternalLogins?.Any() == true) {
          <hr />
          <div class="external-logins">
              <div class="text-muted">@Translate("Or continue with")</div>

              @foreach (var p in Model.ExternalLogins) {
                  <form method="post" class="mt-2">
                      <input type="hidden" name="redirect" value="@redirect" />
                      <button type="submit" name="DwExternalLoginProvider" value="@p.Id" class="btn btn-outline-primary">
                          @if (!string.IsNullOrEmpty(p.Icon)) {
                              <img src="@p.Icon" alt="" style="height:18px; width:auto;" />
                          }
                          <span>@p.Name</span>
                      </button>
                  </form>
              }
          </div>
      }
  }

</div>

Why this works (and what it maps to server-side):

  • Posting username/password triggers normal auth (or MFA step 1).
  • Posting username + dologin triggers passwordless step 1 (OTP/Magic link).
  • When the backend replies with MfaVerificationRequired, the module can send the email (code or magic link) and your template switches to the verification form.
  • Posting dologin + code verifies.
  • Posting DwExternalLoginProvider starts external login.

Method-specific notes and mini examples

1) Username/password (classic)

Minimal form:

<form method="post">
  <input type="hidden" name="redirect" value="@Model.RedirectAfterLogin" />
  <input name="username" required />
  <input name="password" type="password" required />
  <button type="submit">Sign in</button>
</form>

This calls the backend logon directly.

2) MFA (password + verification)

MFA is a two-step UX:

Step 1: username + password (same as classic login)

If credentials are valid, the backend responds with MfaVerificationRequired and (on the first required attempt) sends the verification email.

Step 2: code entry form

<form method="post">
  <input type="hidden" name="redirect" value="@Model.RedirectAfterLogin" />
  <input type="hidden" name="dologin" value="1" />
  <input name="code" autocomplete="one-time-code" required />
  <button type="submit">Verify</button>
</form>

That maps to MFA verification on the server.

3) OTP (passwordless one-time code)

OTP is also two steps, but step 1 is username-only:

Step 1: “send code”

<form method="post">
  <input type="hidden" name="redirect" value="@Model.RedirectAfterLogin" />
  <input name="username" required />
  <input type="hidden" name="dologin" value="1" />
  <button type="submit">Send me a code</button>
</form>

The dologin key is the “yes I really mean it” flag that makes username-only posts legal.

Then show the same code entry form as MFA step 2 (dologin + code).

Same step 1 as OTP (username + dologin) — but the email contains a link. Clicking it hits the site with:

  • DwExtranetMagicLink=<encrypted token>

…which the middleware verifies and signs the user in.

No extra Razor needed beyond your usual “result banner” (users might land back on the login page briefly depending on redirect).

5) External authentication (Google/Azure AD/etc.)

Render buttons like:

@foreach (var p in Model.ExternalLogins) {
  <form method="post">
    <input type="hidden" name="redirect" value="@Model.RedirectAfterLogin" />
    <button type="submit" name="DwExternalLoginProvider" value="@p.Id">
      Continue with @p.Name
    </button>
  </form>
}

That starts the external auth “challenge” flow.

When the provider redirects back, the middleware finalizes sign-in on DwExtranetExternalLogin=true.

Email templates (OTP code + magic link)

When verification is required, the module can send:

  • one-time code email, or
  • magic link email

Which one is sent depends on the active login type.

Templates live under:

  • /Templates/Users/UserAuthentication/Email (email templates)

And the module settings include:

  • OneTimeCodeTemplate, sender name/email, subject
  • MagicLinkTemplate, sender name/email, subject

One-time code email template example

@inherits ViewModelTemplate<Dynamicweb.Users.Frontend.UserAuthentication.Email.OneTimeCodeEmailViewModel>
@using Dynamicweb.Users.Frontend.UserAuthentication.Email

<h1>Your one-time code</h1>
<p><strong>@Model.Code</strong></p>
<p>This code is valid for @Model.CodeLifetime seconds.</p>

<p style="color:#666">
  Requested from @Model.IP using @Model.Browser on @Model.OperationSystem.
</p>

Model fields are defined here.

@inherits ViewModelTemplate<Dynamicweb.Users.Frontend.UserAuthentication.Email.LinkEmailViewModel>
@using Dynamicweb.Users.Frontend.UserAuthentication.Email

<h1>Your sign-in link</h1>
<p><a href="@Model.Link">Click here to sign in</a></p>

<p style="color:#666">
  This link expires at @Model.Expiration.
</p>

Model fields are defined here.

Security + UX details you should actually care about

Code lifetime + max attempts

  • Code lifetime is controlled by /Globalsettings/Modules/Extranet/LogOn/CodeLifetimeInSeconds (minimum 10s, default 120s).
  • Verification attempts are capped (max failed code input attempts = 3).

So in your UI:

  • Show an “expires at” hint using Model.MfaCodeExpiration when present.
  • When MfaVerificationFailed, show a clear message and let users restart the flow.

Redirect handling

You don’t need to reverse-engineer redirect rules. Just post redirect=@Model.RedirectAfterLogin back as a hidden field.

The platform will:

  • Prefer the redirect value if valid and safe,
  • Otherwise try user/group start page,
  • Otherwise referrer,
  • Otherwise /.

Accessibility basics

  • Use real <label for=""> bindings.
  • Use autocomplete="username" / "current-password" / "one-time-code" where relevant.
  • Put the error message near the submit button (people scan there).

Updating an existing Login.cshtml (what to change)

If you have an existing template like the current one (username/password + external providers), you mainly need to add:

  1. A branch that detects Model.Result == MfaVerificationRequired (and failed) and renders the code-entry form.
  2. A passwordless “send code” mode (username + hidden dologin) when Model.LoginType is passwordless.
  3. Add handling for MfaVerificationFailed in your result messages.

Your current template is a good starting point; it already renders results and external providers, but it always requires a password and never shows the verification step.

Reference: key types and where they come from

  • UserAuthenticationViewModelLoginTypeMfaCodeExpirationRedirectAfterLoginExternalLoginsResult, links.
  • UserAuthenticationResultType: includes MFA and external-provider outcomes.
  • Middleware contract (form/query keys): usernamepassworddologincodeDwExternalLoginProviderDwExtranetExternalLoginDwExtranetMagicLink.
  • Email support (OTP + magic link): templates + view models.

 

You must be logged in to post in the forum