Add OAuth support
This commit is contained in:
Neha Gupta 2019-04-08 16:48:19 -07:00 коммит произвёл GitHub
Родитель 7b2a165382
Коммит fd412df85f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
20 изменённых файлов: 440 добавлений и 63 удалений

Просмотреть файл

@ -1,7 +1,9 @@
import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import './App.css';
import { Header } from './components/Header';
import { Login } from './components/Login';
class App extends Component {
constructor(props) {
@ -21,9 +23,17 @@ getCurrentSample(id)
return (
<div className="App">
<Header />
<div className="maincontent">
maincontent
</div>
<Switch>
<Route exact path='/' render={() => {
return (
<div className="maincontent">
maincontent
</div>
);
}}
/>
<Route exact path='/login' component={Login} />
</Switch>
</div>
);
}

Просмотреть файл

@ -61,7 +61,8 @@ class Header extends Component {
user: {}
});
userService.getCurrentUser()
.then(user => this.setState({ user }));
.then(user => this.setState({ user }))
.catch(error => console.log(error));
}
render() {
@ -70,7 +71,7 @@ class Header extends Component {
<div className="headerbar" >
<span>
<Link styles={linkStyles} href="https://azure.microsoft.com/" target="_blank">Microsoft Azure</Link>
{user.firstName !== '' &&
{user && user.firstName && user.firstName !== '' &&
<Persona
styles={personaStyles}
text={user.firstName}

Просмотреть файл

@ -0,0 +1,41 @@
import React, { Component } from 'react';
import { Redirect } from 'react-router-dom';
import { Link as FabricLink } from 'office-ui-fabric-react';
class Login extends Component {
constructor(props) {
super(props);
let { from } = this.props.location.state || { from: { pathname: '/' } };
this.state = {
redirectToReferrer: false,
from: from
};
}
onLogin = () => {
let { from } = this.state;
window.location = `/api/user/login?returnUrl=${from.pathname}`;
}
render() {
let { from, redirectToReferrer } = this.state;
if (redirectToReferrer) {
return <Redirect to={from} />;
}
return (
<div>
<p>You must log in to view the page at {from.pathname}</p>
<FabricLink href={`/api/user/login?returnUrl=${from.pathname}`}>Sign in</FabricLink>
<br/>
<FabricLink href={'/api/user/logout'}>Sign out</FabricLink>
<br/>
<button onClick={this.onLogin}>Sign in</button>
</div>
)
}
}
export { Login };

Просмотреть файл

@ -0,0 +1 @@
export * from './Login';

Просмотреть файл

@ -0,0 +1,11 @@
export function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
const error = (data && data.message) || response.statusText;
return Promise.reject(error);
}
return data;
})
}

Просмотреть файл

@ -0,0 +1 @@
export * from './handle-response';

Просмотреть файл

@ -1,14 +1,28 @@
import { handleResponse } from '../helpers';
export const userService = {
getCurrentUser
};
const dummyUser = {
firstName: 'Nehaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
lastName: 'Gupta',
fullName: 'Neha Gupta',
avatarUrl: 'https://github.com/msnehagup.png'
const useFakeApi = true;
const getFakeUser = () => {
return Promise.resolve({
fullName: 'Aaaaaaaaaaaaaaaaaaa Bbbbbbbbbbbb',
email: 'abc@xyz.com',
avatarUrl: 'https://avatars2.githubusercontent.com/u/45184761?v=4',
firstName: 'Aaaaaaaaaaaaaaaaaaa'
});
};
function getCurrentUser() {
return Promise.resolve(dummyUser);
if (useFakeApi) {
return getFakeUser();
}
const requestOptions = {
method: 'GET'
};
return fetch('/api/user', requestOptions)
.then(handleResponse);
}

Просмотреть файл

@ -1,44 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace ServerlessLibraryAPI.Controllers
{
[Route("api/[controller]")]
public class SampleDataController : Controller
{
private static string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
[HttpGet("[action]")]
public IEnumerable<WeatherForecast> WeatherForecasts()
{
var rng = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
DateFormatted = DateTime.Now.AddDays(index).ToString("d"),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
});
}
public class WeatherForecast
{
public string DateFormatted { get; set; }
public int TemperatureC { get; set; }
public string Summary { get; set; }
public int TemperatureF
{
get
{
return 32 + (int)(TemperatureC / 0.5556);
}
}
}
}
}

Просмотреть файл

@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using ServerlessLibraryAPI.Models;
namespace ServerlessLibraryAPI.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class UserController : ControllerBase
{
[HttpGet("login"), HttpPost("login")]
public IActionResult Login(string returnUrl = "/")
{
if (User.Identity.IsAuthenticated)
{
return new RedirectResult(returnUrl);
}
// 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.
// If no scheme is provided then the DefaultChallengeScheme will be used
return Challenge(new AuthenticationProperties { RedirectUri = returnUrl });
}
[HttpGet("logout"), HttpPost("logout")]
public IActionResult Logout()
{
// Instruct the cookies middleware to delete the local cookie which
// was created after a successful authentication flow.
return SignOut(
new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme);
}
[HttpGet]
[ProducesResponseType(typeof(GitHubUser), 200)]
public IActionResult Get()
{
if (User.Identity.IsAuthenticated)
{
GitHubUser user = new GitHubUser(User);
return Ok(user);
}
return Unauthorized();
}
}
}

Просмотреть файл

@ -0,0 +1,33 @@
using System.Security.Claims;
using static ServerlessLibraryAPI.OAuth.GitHub.GitHubAuthenticationConstants;
namespace ServerlessLibraryAPI.Models
{
public class GitHubUser
{
public GitHubUser()
{
}
public GitHubUser(ClaimsPrincipal claimsPrincipal)
{
this.FullName = claimsPrincipal.FindFirstValue(Claims.Name);
this.Email = claimsPrincipal.FindFirstValue(ClaimTypes.Email);
this.AvatarUrl = claimsPrincipal.FindFirstValue(Claims.Avatar);
}
public string FullName { get; set; }
public string Email { get; set; }
public string AvatarUrl { get; set; }
public string FirstName
{
get
{
return this.FullName.Split(' ')?[0];
}
}
}
}

Просмотреть файл

@ -0,0 +1,16 @@
namespace ServerlessLibraryAPI.OAuth.GitHub
{
/// <summary>
/// Contains constants specific to the <see cref="GitHubAuthenticationHandler"/>.
/// </summary>
public static class GitHubAuthenticationConstants
{
public static class Claims
{
public const string Name = "urn:github:name";
public const string Url = "urn:github:url";
public const string Login = "urn:github:login";
public const string Avatar = "urn:github:avatar";
}
}
}

Просмотреть файл

@ -0,0 +1,58 @@
namespace ServerlessLibraryAPI.OAuth.GitHub
{
/// <summary>
/// Default values used by the GitHub authentication middleware.
/// </summary>
public static class GitHubAuthenticationDefaults
{
/// <summary>
/// Default value for <see cref="AuthenticationScheme.Name"/>.
/// </summary>
public const string AuthenticationScheme = "GitHub";
/// <summary>
/// Default value for <see cref="AuthenticationScheme.DisplayName"/>.
/// </summary>
public const string DisplayName = "GitHub";
/// <summary>
/// Default value for <see cref="AuthenticationSchemeOptions.ClaimsIssuer"/>.
/// </summary>
public const string Issuer = "GitHub";
/// <summary>
/// Default value for <see cref="RemoteAuthenticationOptions.CallbackPath"/>.
/// </summary>
public const string CallbackPath = "/signin-github";
/// <summary>
/// Default value for <see cref="OAuthOptions.AuthorizationEndpoint"/>.
/// </summary>
public const string AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
/// <summary>
/// Default value for <see cref="OAuthOptions.TokenEndpoint"/>.
/// </summary>
public const string TokenEndpoint = "https://github.com/login/oauth/access_token";
/// <summary>
/// Default value for <see cref="OAuthOptions.UserInformationEndpoint"/>.
/// </summary>
public const string UserInformationEndpoint = "https://api.github.com/user";
/// <summary>
/// Default value for <see cref="GitHubAuthenticationOptions.UserEmailsEndpoint"/>.
/// </summary>
public const string UserEmailsEndpoint = "https://api.github.com/user/emails";
/// <summary>
/// Scope for <see cref="UserInformationEndpoint"/>.
/// </summary>
public const string UserInformationScope = "user";
/// <summary>
/// Scope for <see cref="UserEmailsEndpoint"/>.
/// </summary>
public const string UserEmailsScope = "user:email";
}
}

Просмотреть файл

@ -0,0 +1,105 @@
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
namespace ServerlessLibraryAPI.OAuth.GitHub
{
public class GitHubAuthenticationHandler : OAuthHandler<GitHubAuthenticationOptions>
{
public GitHubAuthenticationHandler(
IOptionsMonitor<GitHubAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override async Task<AuthenticationTicket> CreateTicketAsync(ClaimsIdentity identity,
AuthenticationProperties properties, OAuthTokenResponse tokens)
{
var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
if (!response.IsSuccessStatusCode)
{
Logger.LogError("An error occurred while retrieving the user profile: the remote server " +
"returned a {Status} response with the following payload: {Headers} {Body}.",
/* Status: */ response.StatusCode,
/* Headers: */ response.Headers.ToString(),
/* Body: */ await response.Content.ReadAsStringAsync());
throw new HttpRequestException("An error occurred while retrieving the user profile.");
}
var payload = JObject.Parse(await response.Content.ReadAsStringAsync());
var principal = new ClaimsPrincipal(identity);
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload);
context.RunClaimActions(payload);
// When the email address is not public, retrieve it from
// the emails endpoint if the user:email scope is specified.
if (!string.IsNullOrEmpty(Options.UserEmailsEndpoint) &&
!identity.HasClaim(claim => claim.Type == ClaimTypes.Email) && Options.Scope.Contains("user:email"))
{
var address = await GetEmailAsync(tokens);
if (!string.IsNullOrEmpty(address))
{
identity.AddClaim(new Claim(ClaimTypes.Email, address, ClaimValueTypes.String, Options.ClaimsIssuer));
}
}
await Options.Events.CreatingTicket(context);
return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
}
protected virtual async Task<string> GetEmailAsync(OAuthTokenResponse tokens)
{
// See https://developer.github.com/v3/users/emails/ for more information about the /user/emails endpoint.
var request = new HttpRequestMessage(HttpMethod.Get, Options.UserEmailsEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
// Failed requests shouldn't cause an error: in this case, return null to indicate that the email address cannot be retrieved.
var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
if (!response.IsSuccessStatusCode)
{
Logger.LogWarning("An error occurred while retrieving the email address associated with the logged in user: " +
"the remote server returned a {Status} response with the following payload: {Headers} {Body}.",
/* Status: */ response.StatusCode,
/* Headers: */ response.Headers.ToString(),
/* Body: */ await response.Content.ReadAsStringAsync());
return null;
}
var payload = JArray.Parse(await response.Content.ReadAsStringAsync());
return (from address in payload.AsJEnumerable()
where address.Value<bool>("primary")
select address.Value<string>("email")).FirstOrDefault();
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
return base.HandleAuthenticateAsync();
}
protected override Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
{
return base.HandleRemoteAuthenticateAsync();
}
}
}

Просмотреть файл

@ -0,0 +1,41 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Http;
using static ServerlessLibraryAPI.OAuth.GitHub.GitHubAuthenticationConstants;
namespace ServerlessLibraryAPI.OAuth.GitHub
{
/// <summary>
/// Defines a set of options used by <see cref="GitHubAuthenticationHandler"/>.
/// </summary>
public class GitHubAuthenticationOptions : OAuthOptions
{
public GitHubAuthenticationOptions()
{
ClaimsIssuer = GitHubAuthenticationDefaults.Issuer;
CallbackPath = new PathString(GitHubAuthenticationDefaults.CallbackPath);
AuthorizationEndpoint = GitHubAuthenticationDefaults.AuthorizationEndpoint;
TokenEndpoint = GitHubAuthenticationDefaults.TokenEndpoint;
UserInformationEndpoint = GitHubAuthenticationDefaults.UserInformationEndpoint;
//Scope.Add(GitHubAuthenticationDefaults.UserInformationScope);
Scope.Add(GitHubAuthenticationDefaults.UserEmailsScope);
ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
ClaimActions.MapJsonKey(Claims.Name, "name");
ClaimActions.MapJsonKey(Claims.Url, "html_url");
ClaimActions.MapJsonKey(Claims.Login, "login");
ClaimActions.MapJsonKey(Claims.Avatar, "avatar_url");
}
/// <summary>
/// Gets or sets the address of the endpoint exposing
/// the email addresses associated with the logged in user.
/// </summary>
public string UserEmailsEndpoint { get; set; } = GitHubAuthenticationDefaults.UserEmailsEndpoint;
}
}

Просмотреть файл

@ -15,6 +15,14 @@
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express - API only": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ApiOnly": "true"
}
},
"ServerLessLibrary": {
"commandName": "Project",
"launchBrowser": true,

Просмотреть файл

@ -9,6 +9,7 @@
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
<ApplicationInsightsResourceId>/subscriptions/7c1b7bab-00b2-4cb7-924e-205c4f411810/resourcegroups/Default-ApplicationInsights-EastUS/providers/microsoft.insights/components/ServerlessLibrary</ApplicationInsightsResourceId>
<ApplicationInsightsAnnotationResourceId>/subscriptions/7c1b7bab-00b2-4cb7-924e-205c4f411810/resourcegroups/Default-ApplicationInsights-EastUS/providers/microsoft.insights/components/ServerlessLibrary</ApplicationInsightsAnnotationResourceId>
<UserSecretsId>235c2497-239d-47f0-8ea7-af2dd2416d95</UserSecretsId>
</PropertyGroup>
<ItemGroup>

Просмотреть файл

@ -25,6 +25,7 @@ namespace ServerlessLibrary
public static string CosmosAuthkey { get { return config(); } }
public static string Database { get { return "serverlesslibrary"; } }
public static string Collection { get { return "contributions"; } }
public static bool ApiOnly { get { return Boolean.Parse(config("false")); } }
}
}

Просмотреть файл

@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ServerlessLibraryAPI.OAuth.GitHub;
using Swashbuckle.AspNetCore.Swagger;
namespace ServerlessLibrary
@ -22,6 +24,20 @@ namespace ServerlessLibrary
{
services.AddMemoryCache();
services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = GitHubAuthenticationDefaults.AuthenticationScheme;
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOAuth<GitHubAuthenticationOptions, GitHubAuthenticationHandler>(
GitHubAuthenticationDefaults.AuthenticationScheme,
GitHubAuthenticationDefaults.DisplayName,
options => {
options.ClientId = Configuration["Authentication:GitHub:ClientId"]; // these settings need to be present in appSettings (or in secrets.json)
options.ClientSecret = Configuration["Authentication:GitHub:ClientSecret"];
});
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
// In production, the React files will be served from this directory
@ -56,7 +72,6 @@ namespace ServerlessLibrary
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseSpaStaticFiles();
@ -67,6 +82,8 @@ namespace ServerlessLibrary
c.RoutePrefix = "swagger";
});
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
@ -74,15 +91,18 @@ namespace ServerlessLibrary
template: "{controller}/{action=Index}/{id?}");
});
app.UseSpa(spa =>
if (!ServerlessLibrarySettings.ApiOnly)
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
app.UseSpa(spa =>
{
spa.UseReactDevelopmentServer(npmScript: "start");
}
});
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseReactDevelopmentServer(npmScript: "start");
}
});
}
app.Use(async (context, next) =>
{

Просмотреть файл

@ -5,5 +5,8 @@
"System": "Information",
"Microsoft": "Information"
}
},
"ApplicationInsights": {
"InstrumentationKey": ""
}
}

Просмотреть файл

@ -7,5 +7,11 @@
"AllowedHosts": "*",
"ApplicationInsights": {
"InstrumentationKey": "d35b5caf-a276-467c-9ac7-f7f7d84ea171"
},
"Authentication": {
"GitHub": {
"ClientId": "",
"ClientSecret": ""
}
}
}