Add to Slack in dotnetcore without having Identity Framework error: The oauth state was missing or invalid

Add to Slack in dotnetcore without having Identity Framework error: The oauth state was missing or invalid

我正在尝试为我的 slackbot 创建一个非常简单的页面,以便用户可以登录和注册。但是,即使使用他们生成的 "Login with Slack" 按钮,我也会收到错误 "The oauth state was missing or invalid."。同样的错误发生在 "Add to Slack".

我的代码基于 https://dotnetthoughts.net/slack-authentication-with-aspnet-core/。尽管它已经过时,但它是我在网上可以找到的唯一示例。我试着弄清楚我需要改变什么才能让它与 dotnetcore 3 和 Slack 2.0 一起工作,但我已经无计可施了。

在我的服务中,在调用AddMvc等之前有如下内容

services.AddAuthentication(options =>
        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "MyAuthCookieName";
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.MaxAge = TimeSpan.FromDays(7);
        options.ExpireTimeSpan = TimeSpan.FromDays(7);

        options.LoginPath = $"/login";
        options.LogoutPath = $"/logout";
        options.AccessDeniedPath = $"/AccessDenied";
        options.SlidingExpiration = true;
        options.ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
    })
    //.AddSlack(options =>
    //{
    //    options.ClientId = Configuration["Slack:ClientId"];
    //    options.ClientSecret = Configuration["Slack:ClientSecret"];
    //});
    .AddOAuth("Slack", options =>
    {
        options.ClientId = Configuration["Slack:ClientId"];
        options.ClientSecret = Configuration["Slack:ClientSecret"];
        options.CallbackPath = new PathString("/signin-slack");
        options.AuthorizationEndpoint = $"https://slack.com/oauth/authorize";
        options.TokenEndpoint = "https://slack.com/api/oauth.access";
        options.UserInformationEndpoint = "https://slack.com/api/users.identity?token=";
        options.Scope.Add("identity.basic");
        options.Events = new OAuthEvents()
        {
            OnCreatingTicket = async context =>
            {
                var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint + context.AccessToken);
                var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
                response.EnsureSuccessStatusCode();
                var userObject = JObject.Parse(await response.Content.ReadAsStringAsync());
                var user = userObject.SelectToken("user");
                var userId = user.Value<string>("id");

                if (!string.IsNullOrEmpty(userId))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }

                var fullName = user.Value<string>("name");
                if (!string.IsNullOrEmpty(fullName))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.Name, fullName, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }
            }
        };
    });

我的配置方法看起来像

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.Map("/login", builder =>
{
    builder.Run(async context =>
    {
        await context.ChallengeAsync("Slack", properties: new AuthenticationProperties { RedirectUri = "/" });
    });
});

app.Map("/logout", builder =>
{
    builder.Run(async context =>
    {
        await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        context.Response.Redirect("/");
    });
});

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
    endpoints.MapRazorPages();
});

除了 "oauth state was missing on invalid" 之外,如果在我的应用程序中我直接转到 /login 我没有收到错误,但我似乎没有登录,因为 User.Identity.IsAuthenticated 是假的。

我真的很茫然,需要一些非常感谢的帮助!

谢谢!

大规模更新

我登录到 Slack 可以正常工作,但是我无法让“添加到 Slack”按钮正常工作。

这是我的新服务:

services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    })
    .AddCookie(options =>
    {
        options.LoginPath = "/login";
        options.LogoutPath = "/logout";
    })
     .AddSlack(options =>
    {
        options.ClientId = Configuration["Slack:ClientId"];
        options.ClientSecret = Configuration["Slack:ClientSecret"];
        options.CallbackPath =  $"{SlackAuthenticationDefaults.CallbackPath}?state={Guid.NewGuid():N}";
        options.ReturnUrlParameter = new PathString("/");
        options.Events = new OAuthEvents()
        {
            OnCreatingTicket = async context =>
            {
                var request = new HttpRequestMessage(HttpMethod.Get, $"{context.Options.UserInformationEndpoint}?token={context.AccessToken}");
                var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
                response.EnsureSuccessStatusCode();
                var userObject = JObject.Parse(await response.Content.ReadAsStringAsync());
                var user = userObject.SelectToken("user");
                var userId = user.Value<string>("id");

                if (!string.IsNullOrEmpty(userId))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }

                var fullName = user.Value<string>("name");
                if (!string.IsNullOrEmpty(fullName))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.Name, fullName, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                }
            }
        };
    });

Per @timur,我抓取了我的 app.Map 并使用了身份验证控制器:

public class AuthenticationController : Controller
{
    [HttpGet("~/login")]
    public async Task<IActionResult> SignIn()
    {
        return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Slack");
    }

    [HttpGet("~/signin-slack")]
    public IActionResult SignInSlack()
    {
        return RedirectToPage("/Index");
    }

    [HttpGet("~/logout"), HttpPost("~/logout")]
    public IActionResult SignOut()
    {
        return SignOut(new AuthenticationProperties { RedirectUri = "/" },
            CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

"Add to Slack" 按钮由 Slack 提供。

<a href="https://slack.com/oauth/authorize?scope=incoming-webhook,commands,bot&client_id=#############"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>

因此,当用户单击 "Login" 时,它会登录并获取他们的姓名等。您会注意到在我的身份验证控制器中我添加了一个路径为“~/signin-slack”的函数这是因为我手动添加了 "Options.CallbackPath" 以添加状态参数。如果我删除 "Options.CallbackPath",我会收到一条错误消息,指出 oauth 状态丢失或无效。

所以,我不确定我在 Slack 方面缺少什么。他们让它听起来很简单!

抱歉这么久 post/update。感谢您的帮助。

您提到的同一篇文章下方有一个 link 指向 AspNet.Security.OAuth.Providers 源代码库。这似乎相当活跃,并支持包括 Slack 在内的其他 oAuth 目标的 HEAPS。

我假设您已经创建并配置了您的 Slack 应用程序。 Redirect URL 部分在那里是最重要的,因为它很重要你是指定 http 还是 https 回调(我的例子只在我去了 https)。

综上所述,我相信实施它的一般方法是

Install-Package AspNet.Security.OAuth.Slack -Version 3.0.0

并像这样编辑您的 Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(options => { /* your options verbatim */ })
            .AddSlack(options =>
            {
                options.ClientId = "xxx";
                options.ClientSecret = "xxx";
            });
}

我看到您选择直接在 Startup class 中映射您的 login/logout 路由,这实际上可能是问题所在 - 调用 .Map() 分支请求管道,因此您不这样做'点击你之前设置的同一个中间件链),所以我使用了一个单独的控制器(根据 sample app):

public class AuthenticationController : Controller
    {
        [HttpGet("~/signin")]
        public async Task<IActionResult> SignIn()
        {
            // Instruct the middleware corresponding to the requested external identity
            // provider to redirect the user agent to its own authorization endpoint.
            // Note: the authenticationScheme parameter must match the value configured in Startup.cs
            return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Slack");
        }

        [HttpGet("~/signout"), HttpPost("~/signout")]
        public IActionResult SignOut()
        {
            // Instruct the cookies middleware to delete the local cookie created
            // when the user agent is redirected from the external identity provider
            // after a successful authentication flow (e.g Google or Facebook).
            return SignOut(new AuthenticationProperties { RedirectUri = "/" },
                CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }

查看您的代码片段,我怀疑您已经安装了这个 nuget 包并尝试使用它。这让我推荐了一些要检查的东西:

  1. 仔细检查您在 slack 应用程序配置中的重定向 URL,
  2. 检查您的 identity.basic 范围是否确实为您的应用启用
  3. 尝试在单独的控制器中处理登录操作而不是启动 class
  4. 确保您的应用程序使用 SSL 运行:**Project properties** -> **Debug** tab -> **Enable SSL** checkbox(如果 IIS Express 托管,否则您可能需要执行
  5. 查看 the sample project,它可能会让您了解您的设置有何不同

UPD:所以经过一些来回我能够更好地了解您的问题。我确实相信您所观察到的与使用 slack 登录是分开的,而与他们的应用程序安装流程有关。正如您已经指出的那样,"add to slack" 流程和用户登录之间的区别是 - state 参数不是您的来源 URL 的一部分,因此不会通过请求返回给您。这对 oAuth 处理程序来说意义重大,因为它依赖于 state 来验证请求完整性,如果状态为空则失败。有 discussion on github 但我认为结果是 - 您将不得不自己跳过验证部分。 所以我继承了 nuget 包附带的 SlackAuthenticationHandler 并删除了给我问题的代码位:

    public class SlackNoStateAuthenticationHandler : SlackAuthenticationHandler {
        public SlackNoStateAuthenticationHandler([NotNull] IOptionsMonitor<SlackAuthenticationOptions> options,
            [NotNull] ILoggerFactory logger,
            [NotNull] UrlEncoder encoder,
            [NotNull] ISystemClock clock) : base(options, logger, encoder, clock) { }

        public void GenerateCorrelationIdPublic(AuthenticationProperties properties)
        {
            GenerateCorrelationId(properties);
        }

        protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
        {
            var query = Request.Query;

            var state = query["state"];
            var properties = Options.StateDataFormat.Unprotect(state);

            var error = query["error"];
            if (!StringValues.IsNullOrEmpty(error))
            {
                // Note: access_denied errors are special protocol errors indicating the user didn't
                // approve the authorization demand requested by the remote authorization server.
                // Since it's a frequent scenario (that is not caused by incorrect configuration),
                // denied errors are handled differently using HandleAccessDeniedErrorAsync().
                // Visit https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for more information.
                if (StringValues.Equals(error, "access_denied"))
                {
                    return await HandleAccessDeniedErrorAsync(properties);
                }

                var failureMessage = new StringBuilder();
                failureMessage.Append(error);
                var errorDescription = query["error_description"];
                if (!StringValues.IsNullOrEmpty(errorDescription))
                {
                    failureMessage.Append(";Description=").Append(errorDescription);
                }
                var errorUri = query["error_uri"];
                if (!StringValues.IsNullOrEmpty(errorUri))
                {
                    failureMessage.Append(";Uri=").Append(errorUri);
                }

                return HandleRequestResult.Fail(failureMessage.ToString(), properties);
            }

            var code = query["code"];

            if (StringValues.IsNullOrEmpty(code))
            {
                return HandleRequestResult.Fail("Code was not found.", properties);
            }


            var tokens = await ExchangeCodeAsync(new OAuthCodeExchangeContext(properties, code, BuildRedirectUri(Options.CallbackPath)));

            if (tokens.Error != null)
            {
                return HandleRequestResult.Fail(tokens.Error, properties);
            }

            if (string.IsNullOrEmpty(tokens.AccessToken))
            {
                return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
            }

            var identity = new ClaimsIdentity(ClaimsIssuer);

            if (Options.SaveTokens)
            {
                var authTokens = new List<AuthenticationToken>();

                authTokens.Add(new AuthenticationToken { Name = "access_token", Value = tokens.AccessToken });
                if (!string.IsNullOrEmpty(tokens.RefreshToken))
                {
                    authTokens.Add(new AuthenticationToken { Name = "refresh_token", Value = tokens.RefreshToken });
                }

                if (!string.IsNullOrEmpty(tokens.TokenType))
                {
                    authTokens.Add(new AuthenticationToken { Name = "token_type", Value = tokens.TokenType });
                }

                if (!string.IsNullOrEmpty(tokens.ExpiresIn))
                {
                    int value;
                    if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
                    {
                        // https://www.w3.org/TR/xmlschema-2/#dateTime
                        // https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx
                        var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value);
                        authTokens.Add(new AuthenticationToken
                        {
                            Name = "expires_at",
                            Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
                        });
                    }
                }

                properties.StoreTokens(authTokens);
            }

            var ticket = await CreateTicketAsync(identity, properties, tokens);
            if (ticket != null)
            {
                return HandleRequestResult.Success(ticket);
            }
            else
            {
                return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
            }
        }
    }

此代码的大部分是 relevant source 的逐字副本,因此您可以根据需要随时进行更多更改;

然后我们需要将敏感的状态参数注入你的URL。假设您有一个控制器和一个视图:

家庭控制器

public class HomeController : Controller
    { 
        private readonly IAuthenticationHandlerProvider _handler;

        public HomeController(IAuthenticationHandlerProvider handler)
        {
            _handler = handler;
        }

        public async Task<IActionResult> Index()
        {
            var handler = await _handler.GetHandlerAsync(HttpContext, "Slack") as SlackNoStateAuthenticationHandler; // we'd get the configured instance
            var props = new AuthenticationProperties { RedirectUri = "/" }; // provide some sane defaults
            handler.GenerateCorrelationIdPublic(props); // generate xsrf token and add it into the properties object
            ViewBag.state = handler.Options.StateDataFormat.Protect(props); // and push it into your view.
            return View();
        }
}

Startup.cs

.AddOAuth<SlackAuthenticationOptions, SlackNoStateAuthenticationHandler>(SlackAuthenticationDefaults.AuthenticationScheme, SlackAuthenticationDefaults.DisplayName, options =>
            {
                options.ClientId = "your_id";
                options.ClientSecret = "your_secret";
            });

Index.cshtml

<a href="https://slack.com/oauth/authorize?client_id=<your_id>&scope=identity.basic&state=@ViewBag.state"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x"></a>

这让我成功完成了请求,尽管我不完全确定这样做是否会被视为最佳做法

所以我想通了。登录与 "Add to Slack" 功能完全分开。

所以,对于登录,我的服务是:

var slackState = Guid.NewGuid().ToString("N");

services.AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        })
        .AddCookie(options =>
        {
            options.LoginPath = "/login";
            options.LogoutPath = "/logout";
        })
         .AddSlack(options =>
        {
            options.ClientId = Configuration["Slack:ClientId"];
            options.ClientSecret = Configuration["Slack:ClientSecret"];
            options.CallbackPath = $"{SlackAuthenticationDefaults.CallbackPath}?state={slackState}";
            options.ReturnUrlParameter = new PathString("/");
            options.Events = new OAuthEvents()
            {
                OnCreatingTicket = async context =>
                {
                    var request = new HttpRequestMessage(HttpMethod.Get, $"{context.Options.UserInformationEndpoint}?token={context.AccessToken}");
                    var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
                    response.EnsureSuccessStatusCode();
                    var userObject = JObject.Parse(await response.Content.ReadAsStringAsync());
                    var user = userObject.SelectToken("user");
                    var userId = user.Value<string>("id");


                    if (!string.IsNullOrEmpty(userId))
                    {
                        context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                    }

                    var fullName = user.Value<string>("name");
                    if (!string.IsNullOrEmpty(fullName))
                    {
                        context.Identity.AddClaim(new Claim(ClaimTypes.Name, fullName, ClaimValueTypes.String, context.Options.ClaimsIssuer));
                    }
                }
            };
        });

我的 AuthenticationController 现在看起来像:

public class AuthenticationController : Controller
{
    private readonly ILogger<AuthenticationController> _logger;
    private readonly AppSettings _appSettings;

    public AuthenticationController(ILogger<AuthenticationController> logger, IOptionsMonitor<AppSettings> appSettings)
    {
        _logger = logger;
        _appSettings = appSettings.CurrentValue;
    }

    [HttpGet("~/login")]
    public IActionResult SignIn()
    {
        return Challenge(new AuthenticationProperties { RedirectUri = "/" }, "Slack");
    }

    [HttpGet("~/signin-slack")]
    public async Task<IActionResult> SignInSlack()
    {
        var clientId = _appSettings.Slack.ClientId;
        var clientSecret = _appSettings.Slack.ClientSecret;
        var code = Request.Query["code"];

        SlackAuthRequest slackAuthRequest;
        string responseMessage;

        var requestUrl = $"https://slack.com/api/oauth.access?client_id={clientId}&client_secret={clientSecret}&code={code}";
        var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
        using (var client = new HttpClient())
        {
            var response = await client.SendAsync(request).ConfigureAwait(false);
            var result = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
            slackAuthRequest = JsonConvert.DeserializeObject<SlackAuthRequest>(result);
        }

        if (slackAuthRequest != null)
        {
            _logger.LogInformation("New installation of StanLeeBot for {TeamName} in {Channel}", slackAuthRequest.TeamName, slackAuthRequest.IncomingWebhook.Channel);

            var webhookUrl = slackAuthRequest.IncomingWebhook.Url;

            var sbmClient = new SbmClient(webhookUrl);
            var message = new Message
            {
                Text = "Hi there from StanLeeBot!"
            };
            await sbmClient.SendAsync(message).ConfigureAwait(false);

            responseMessage = $"Congrats! StanLeeBot has been successfully added to {slackAuthRequest.TeamName} {slackAuthRequest.IncomingWebhook.Channel}";
            return RedirectToPage("/Index", new { message = responseMessage });
        }

        _logger.LogError("Something went wrong making a request to {RequestUrl}", requestUrl);

        responseMessage = "Error: Something went wrong and we were unable to add StanLeeBot to your Slack.";
        return RedirectToPage("/Index", new { message = responseMessage });
    }

    [HttpGet("~/logout"), HttpPost("~/logout")]
    public IActionResult SignOut()
    {
        return SignOut(new AuthenticationProperties { RedirectUri = "/" },
            CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

SmbClient 是一个名为 SlackBotMessages 的 Nuget 包,用于发送消息。因此,在用户通过身份验证后,会自动向该频道发送一条欢迎用户的消息。

非常感谢大家的帮助!让我知道您的想法,或者您是否发现任何陷阱。