diff --git a/ServerlessLibraryAPI/ClientApp/src/App.js b/ServerlessLibraryAPI/ClientApp/src/App.js index 087605d..c7889f3 100644 --- a/ServerlessLibraryAPI/ClientApp/src/App.js +++ b/ServerlessLibraryAPI/ClientApp/src/App.js @@ -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 (
-
- maincontent -
+ + { + return ( +
+ maincontent +
+ ); + }} + /> + +
); } diff --git a/ServerlessLibraryAPI/ClientApp/src/components/Header/Header.js b/ServerlessLibraryAPI/ClientApp/src/components/Header/Header.js index 23c8c9a..d550dbc 100644 --- a/ServerlessLibraryAPI/ClientApp/src/components/Header/Header.js +++ b/ServerlessLibraryAPI/ClientApp/src/components/Header/Header.js @@ -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 {
Microsoft Azure - {user.firstName !== '' && + {user && user.firstName && user.firstName !== '' && { + let { from } = this.state; + window.location = `/api/user/login?returnUrl=${from.pathname}`; + } + + render() { + let { from, redirectToReferrer } = this.state; + + if (redirectToReferrer) { + return ; + } + return ( +
+

You must log in to view the page at {from.pathname}

+ Sign in +
+ Sign out +
+ +
+ ) + } +} + +export { Login }; diff --git a/ServerlessLibraryAPI/ClientApp/src/components/Login/index.js b/ServerlessLibraryAPI/ClientApp/src/components/Login/index.js new file mode 100644 index 0000000..a10c3a8 --- /dev/null +++ b/ServerlessLibraryAPI/ClientApp/src/components/Login/index.js @@ -0,0 +1 @@ +export * from './Login'; diff --git a/ServerlessLibraryAPI/ClientApp/src/helpers/handle-response.js b/ServerlessLibraryAPI/ClientApp/src/helpers/handle-response.js new file mode 100644 index 0000000..21e8c09 --- /dev/null +++ b/ServerlessLibraryAPI/ClientApp/src/helpers/handle-response.js @@ -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; + }) +} diff --git a/ServerlessLibraryAPI/ClientApp/src/helpers/index.js b/ServerlessLibraryAPI/ClientApp/src/helpers/index.js new file mode 100644 index 0000000..6a28af2 --- /dev/null +++ b/ServerlessLibraryAPI/ClientApp/src/helpers/index.js @@ -0,0 +1 @@ +export * from './handle-response'; \ No newline at end of file diff --git a/ServerlessLibraryAPI/ClientApp/src/services/user.service.js b/ServerlessLibraryAPI/ClientApp/src/services/user.service.js index d1c629b..66475ac 100644 --- a/ServerlessLibraryAPI/ClientApp/src/services/user.service.js +++ b/ServerlessLibraryAPI/ClientApp/src/services/user.service.js @@ -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); } diff --git a/ServerlessLibraryAPI/Controllers/SampleDataController.cs b/ServerlessLibraryAPI/Controllers/SampleDataController.cs deleted file mode 100644 index 6b94c9e..0000000 --- a/ServerlessLibraryAPI/Controllers/SampleDataController.cs +++ /dev/null @@ -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 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); - } - } - } - } -} diff --git a/ServerlessLibraryAPI/Controllers/UsersController.cs b/ServerlessLibraryAPI/Controllers/UsersController.cs new file mode 100644 index 0000000..2b78dda --- /dev/null +++ b/ServerlessLibraryAPI/Controllers/UsersController.cs @@ -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(); + } + } +} \ No newline at end of file diff --git a/ServerlessLibraryAPI/Models/GitHubUser.cs b/ServerlessLibraryAPI/Models/GitHubUser.cs new file mode 100644 index 0000000..3cf030b --- /dev/null +++ b/ServerlessLibraryAPI/Models/GitHubUser.cs @@ -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]; + } + } + } +} diff --git a/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationConstants.cs b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationConstants.cs new file mode 100644 index 0000000..3e4e522 --- /dev/null +++ b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationConstants.cs @@ -0,0 +1,16 @@ +namespace ServerlessLibraryAPI.OAuth.GitHub +{ + /// + /// Contains constants specific to the . + /// + 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"; + } + } +} diff --git a/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationDefaults.cs b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationDefaults.cs new file mode 100644 index 0000000..346f2a2 --- /dev/null +++ b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationDefaults.cs @@ -0,0 +1,58 @@ +namespace ServerlessLibraryAPI.OAuth.GitHub +{ + /// + /// Default values used by the GitHub authentication middleware. + /// + public static class GitHubAuthenticationDefaults + { + /// + /// Default value for . + /// + public const string AuthenticationScheme = "GitHub"; + + /// + /// Default value for . + /// + public const string DisplayName = "GitHub"; + + /// + /// Default value for . + /// + public const string Issuer = "GitHub"; + + /// + /// Default value for . + /// + public const string CallbackPath = "/signin-github"; + + /// + /// Default value for . + /// + public const string AuthorizationEndpoint = "https://github.com/login/oauth/authorize"; + + /// + /// Default value for . + /// + public const string TokenEndpoint = "https://github.com/login/oauth/access_token"; + + /// + /// Default value for . + /// + public const string UserInformationEndpoint = "https://api.github.com/user"; + + /// + /// Default value for . + /// + public const string UserEmailsEndpoint = "https://api.github.com/user/emails"; + + /// + /// Scope for . + /// + public const string UserInformationScope = "user"; + + /// + /// Scope for . + /// + public const string UserEmailsScope = "user:email"; + } +} diff --git a/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationHandler.cs b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationHandler.cs new file mode 100644 index 0000000..714b63f --- /dev/null +++ b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationHandler.cs @@ -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 + { + public GitHubAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + } + + protected override async Task 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 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("primary") + select address.Value("email")).FirstOrDefault(); + } + + protected override Task HandleAuthenticateAsync() + { + return base.HandleAuthenticateAsync(); + } + + protected override Task HandleRemoteAuthenticateAsync() + { + return base.HandleRemoteAuthenticateAsync(); + } + } +} diff --git a/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationOptions.cs b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationOptions.cs new file mode 100644 index 0000000..ddf5bde --- /dev/null +++ b/ServerlessLibraryAPI/OAuth.GitHub/GitHubAuthenticationOptions.cs @@ -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 +{ + /// + /// Defines a set of options used by . + /// + 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"); + } + + /// + /// Gets or sets the address of the endpoint exposing + /// the email addresses associated with the logged in user. + /// + public string UserEmailsEndpoint { get; set; } = GitHubAuthenticationDefaults.UserEmailsEndpoint; + } +} diff --git a/ServerlessLibraryAPI/Properties/launchSettings.json b/ServerlessLibraryAPI/Properties/launchSettings.json index 13fbfb9..5a69813 100644 --- a/ServerlessLibraryAPI/Properties/launchSettings.json +++ b/ServerlessLibraryAPI/Properties/launchSettings.json @@ -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, diff --git a/ServerlessLibraryAPI/ServerlessLibraryAPI.csproj b/ServerlessLibraryAPI/ServerlessLibraryAPI.csproj index 990c0a0..0339026 100644 --- a/ServerlessLibraryAPI/ServerlessLibraryAPI.csproj +++ b/ServerlessLibraryAPI/ServerlessLibraryAPI.csproj @@ -9,6 +9,7 @@ $(DefaultItemExcludes);$(SpaRoot)node_modules\** /subscriptions/7c1b7bab-00b2-4cb7-924e-205c4f411810/resourcegroups/Default-ApplicationInsights-EastUS/providers/microsoft.insights/components/ServerlessLibrary /subscriptions/7c1b7bab-00b2-4cb7-924e-205c4f411810/resourcegroups/Default-ApplicationInsights-EastUS/providers/microsoft.insights/components/ServerlessLibrary + 235c2497-239d-47f0-8ea7-af2dd2416d95 diff --git a/ServerlessLibraryAPI/ServerlessLibrarySettings.cs b/ServerlessLibraryAPI/ServerlessLibrarySettings.cs index 6d8ea20..34ebb8e 100644 --- a/ServerlessLibraryAPI/ServerlessLibrarySettings.cs +++ b/ServerlessLibraryAPI/ServerlessLibrarySettings.cs @@ -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")); } } } } diff --git a/ServerlessLibraryAPI/Startup.cs b/ServerlessLibraryAPI/Startup.cs index fb36e83..1681b4f 100644 --- a/ServerlessLibraryAPI/Startup.cs +++ b/ServerlessLibraryAPI/Startup.cs @@ -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( + 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) => { diff --git a/ServerlessLibraryAPI/appsettings.Development.json b/ServerlessLibraryAPI/appsettings.Development.json index e203e94..235d51e 100644 --- a/ServerlessLibraryAPI/appsettings.Development.json +++ b/ServerlessLibraryAPI/appsettings.Development.json @@ -5,5 +5,8 @@ "System": "Information", "Microsoft": "Information" } + }, + "ApplicationInsights": { + "InstrumentationKey": "" } } diff --git a/ServerlessLibraryAPI/appsettings.json b/ServerlessLibraryAPI/appsettings.json index 5da4926..11313b9 100644 --- a/ServerlessLibraryAPI/appsettings.json +++ b/ServerlessLibraryAPI/appsettings.json @@ -7,5 +7,11 @@ "AllowedHosts": "*", "ApplicationInsights": { "InstrumentationKey": "d35b5caf-a276-467c-9ac7-f7f7d84ea171" + }, + "Authentication": { + "GitHub": { + "ClientId": "", + "ClientSecret": "" + } } } \ No newline at end of file