Bot Framework (v4) provides OAuth sign-in feature to authenticate against an OAuth login provider. However, there are few samples showing how to use Sign-in Card to authenticate against a third party login service. This article will show you how to use Sign-in Card to authenticate a third party login service.
Sign-in Card demo
This sample is based on an EchoBot template and it will have following sign-in ability. It does not have a real sign-in logic, it accepts any value user inputs for the UserName
.
Step 1: Define User class
A CacheUser
represents a Bot conversation user, it links the Bot client user id and the ConversationId to an authenticated user. In this sample, an authenticated user only has one UserName
field, before login the default value of UserName
is an empty string: "".
public class CacheUser
{
public string UserName { get; set; }
public string BotClientUserId { get; set; }
public string BotConversationId { get; set; }
public ITurnContext TurnContext { get; set; }
public CancellationToken CancellationToken { get; set; }
public bool LoginDetected { get; set; }
public CacheUser(string botClientUserId, string botConversationId, ITurnContext turnContext, System.Threading.CancellationToken cancellationToken)
{
BotClientUserId = botClientUserId;
BotConversationId = botConversationId;
UserName = "";
TurnContext = turnContext;
CancellationToken = cancellationToken;
LoginDetected = false;
}
}
Step 2: Show Sign-in Card as welcome message and instantiate the user
In this sample, we will show Sign-in Card as welcome message. To support WebChat welcome message, I implemented customized welcome event for DirectLine and WebChat, see https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/15.d.backchannel-send-welcome-event for details.
The login URL of the Sign-in card needs to contain the Bot client user id and ConversationId by reading them from turnContext.Activity.From.Id
and turnContext.Activity.Conversation.Id
.
When presenting the Sign-in Card to current user for the first time, it instantiates the user and save it to the in-memory cache. By that time, the username is "".
protected override async Task OnEventActivityAsync(ITurnContext<IEventActivity> turnContext, CancellationToken cancellationToken)
{
if (turnContext.Activity.Name == "webchat/join")
{
await ShowSigninCard(turnContext, cancellationToken);
}
}
protected override async Task OnMembersAddedAsync(IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken)
{
if (turnContext.Activity.ChannelId != "directline" && turnContext.Activity.ChannelId != "webchat")
{
foreach (var member in membersAdded)
{
if (member.Id != turnContext.Activity.Recipient.Id)
{
await ShowSigninCard(turnContext, cancellationToken);
}
}
}
}
private async Task ShowSigninCard(ITurnContext turnContext, CancellationToken cancellationToken)
{
string botClientUserId = turnContext.Activity.From.Id;
string botConversationId = turnContext.Activity.Conversation.Id;
var loginUrl = "http://localhost:3978/login.html?userid={botClientUserId}&conversationid={botConversationId}";
var attachments = new List<Attachment>();
var reply = MessageFactory.Attachment(attachments);
var signinCard = new SigninCard
{
Text = "BotFramework Sign-in Card",
Buttons = new List<CardAction> { new CardAction(ActionTypes.Signin, "Sign-in", value: loginUrl) },
};
reply.Attachments.Add(signinCard.ToAttachment());
List<CacheUser> users;
if (!Cache.TryGetValue("users", out users))
{
users = new List<CacheUser>();
}
if (!users.Any(u => u.BotClientUserId == botClientUserId && u.BotConversationId == botConversationId))
{
users.Add(new CacheUser(botClientUserId, botConversationId, turnContext, cancellationToken));
Cache.Set("users", users, new MemoryCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromDays(7)));
}
await turnContext.SendActivityAsync(reply, cancellationToken);
}
Step 3: Pass the BotClientUserId and ConversationId to the login API
The login page is a static page which only contains a simple form to allow the user to input the user name he/she wants to sign in. The form has two hidden fields recording the userid
and conversationid
, we need to use JavaScript to read these values from query string and set them properly.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<form method="post" action="api/login">
User Name: <input type="text" name="username" placeholder="Input your user name" required />
<br />
Password: <input type="password" name="password" value="123456" readonly />
<br />
<input type="hidden" name="userid" value="" />
<input type="hidden" name="conversationid" value="" />
<input type="submit" value="Login" />
</form>
<script>
var parseRegexp = /^.+userid=(.+)&conversationid=(.+)$/i;
var execResult = parseRegexp.exec(location.search);
if (execResult) {
document.getElementsByName('userid')[0].value = execResult[1];
document.getElementsByName('conversationid')[0].value = execResult[2];
}
</script>
</body>
</html>
Step 4: Implement the Login API
The Login API reads required values from the post body. It then finds the user instance from the cached user list by botClientUserId
and botConversationId
. After that it sets the UserName
of that user instance to complete the sign-in process.
[HttpPost("login")]
public async Task<IActionResult> Login()
{
StringValues botClientUserId;
StringValues userName;
StringValues botConversationId;
if (Request.Form.TryGetValue("userid", out botClientUserId) && Request.Form.TryGetValue("username", out userName) && Request.Form.TryGetValue("conversationid", out botConversationId))
{
List<CacheUser> users;
if (!Cache.TryGetValue("users", out users))
{
users = new List<CacheUser>();
}
CacheUser user;
user = users.Find(u => u.BotClientUserId == botClientUserId && u.BotConversationId == botConversationId);
if (user != null)
{
user.UserName = userName;
user.LoginDetected = false;
Cache.Set("users", users, new MemoryCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromDays(7)));
Logger.LogInformation($"Login User: {userName}, BotClientUserId: {botClientUserId}, BotConversationId: {botConversationId}");
var content = $"Hi {userName}, you have successfully logged in! You may close this window.";
return new ContentResult()
{
Content = content,
ContentType = "text/html"
};
}
else
{
return StatusCode(500);
}
}
else
{
return BadRequest();
}
}
Step 5: Implement a UserLoginDetectService
to detect user login event every 2 seconds in the background
For better user experience, we need to notify the user that he/she has successfully signed in after he/she completes login in the webpage. To achieve this, I implemented a background service to detect new user logins every two seconds. It goes through all users and find out newly signed in users who has a valid UserName
and the LoginDetected
is false
. It then sends the successful sign-in message on this user's own TurnContext
and set it's LoginDetected
to true
to prevent the user being notified again in next run.
class UserLoginDetectService : IHostedService, IDisposable
{
private readonly ILogger Logger;
private Timer Timer;
private IMemoryCache Cache;
public UserLoginDetectService(ILogger<UserLoginDetectService> logger, IMemoryCache cache)
{
Logger = logger;
Cache = cache;
}
public Task StartAsync(CancellationToken cancellationToken)
{
Logger.LogInformation("UserLoginDetectService is starting.");
Timer = new Timer(DoWork, null, TimeSpan.Zero,
TimeSpan.FromSeconds(2));
return Task.CompletedTask;
}
private void DoWork(object state)
{
List<CacheUser> users = new List<CacheUser>();
if (Cache.TryGetValue("users", out users))
{
for (int i = 0; i < users.Count; i++)
{
var user = users[i];
if (user.UserName != "" && !user.LoginDetected)
{
var text = $"Hi {users[i].UserName}, you have successfully logged in!";
users[i].LoginDetected = true;
users[i].TurnContext.SendActivityAsync(MessageFactory.Text(text, text), users[i].CancellationToken);
Cache.Set("users", users);
}
}
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
Timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
public void Dispose()
{
Timer?.Dispose();
}
}
Step 6: Default message handling
This part is quite simple, it checks whether the user is already signed in, if not it presents the Sign-in Card again.
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
CacheUser user = GetLogOnUser(turnContext);
if (user == null)
{
var text = "Please login first";
await turnContext.SendActivityAsync(MessageFactory.Text(text, text), cancellationToken);
await ShowSigninCard(turnContext, cancellationToken);
}
else
{
var text = $"Hi {user.UserName}, welcome to use the Bot!";
await turnContext.SendActivityAsync(MessageFactory.Text(text, text), cancellationToken);
}
}
private CacheUser GetLogOnUser(ITurnContext turnContext)
{
CacheUser user = null;
string userid = turnContext.Activity.From.Id;
string conversationId = turnContext.Activity.Conversation.Id;
List<CacheUser> users;
if (Cache.TryGetValue("users", out users))
{
user = users.Find(u => u.BotClientUserId == userid && u.BotConversationId == conversationId && u.UserName != "");
}
return user;
}
Step 7: Don't forget to register the ServiceCollection
This sample uses MemoryCache
to record Bot users and HostedService
to notify newly signed in users. We need to register them in Startup.cs
so we can use them.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
// Create the Bot Framework Adapter.
services.AddSingleton<IBotFrameworkHttpAdapter, AdapterWithErrorHandler>();
// Create the bot as a transient. In this case the ASP Controller is expecting an IBot.
services.AddTransient<IBot, EchoBot>();
services.AddMemoryCache();
services.AddHostedService<UserLoginDetectService>();
}
For more information about MemoryCache
, you can read: Cache in-memory in ASP.NET Core
For more information about HostedService
, you can read: Background tasks with hosted services in ASP.NET Core
Takeaway
The core mechanisms of this sample are:
- Link the unique Bot client user id and Conversation Id with the sign-in user.
- Run a detection service to notify newly signed in users in the background repeatedly.
In a real Bot project, the login logic doesn't have to be in the same running context of the Bot application, you can totally use an external login service as long as you pass the BotClientUserId
and ConversationId
to the login service. And the user data can be saved in anywhere like a database.