Search code examples
angularasp.net-corefacebook-logingoogle-signinasp.net-core-identity

ASP.NET Core and Angular: Microsoft Authentication


For the moment I'm trying to add third party authentication to my ASP.NET Core web application. Today I've successfully implemented Facebook authentication. This was already a struggle since the docs only mention Facebook authentication for a ASP.NET application with razor pages (https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/facebook-logins?view=aspnetcore-2.2). Nothing has been written in the docs about implementing this for Angular apps.

This was the most complete walkthrough I found for ASP.NET Core + Angular + FB auth: https://fullstackmark.com/post/13/jwt-authentication-with-aspnet-core-2-web-api-angular-5-net-core-identity-and-facebook-login

I'm using Microsoft.AspNetCore.Identity, this package already manages a lot for you. But I can't find how to get started implementing Microsoft, Google or even Twitter login in a web app. The docs don't seem to cover that part...

My GitHub repo: https://github.com/MusicDemons/MusicDemons-ASP-NET

Anyone had any experience with this?


Solution

  • google-login.component.html

    <button class="btn btn-secondary google-login-btn" [disabled]="isOpen" (click)="launchGoogleLogin()">
        <i class="fa fa-google"></i>
        Login with Google
    </button>
    

    google-login.component.scss

    .google-login-btn {
        background: #fff;
        color: #333;
        padding: 5px 10px;
    
        &:not([disabled]):hover {
          background: #eee;
        }
    }
    

    google-login.component.ts

    import { Component, Output, EventEmitter, Inject } from '@angular/core';
    import { AuthService } from '../../../services/auth.service';
    import { Router } from '@angular/router';
    import { LoginResult } from '../../../entities/loginResult';
    
    @Component({
      selector: 'app-google-login',
      templateUrl: './google-login.component.html',
      styleUrls: [
        './google-login.component.scss'
      ]
    })
    export class GoogleLoginComponent {
    
      private authWindow: Window;
      private isOpen: boolean = false;
    
      @Output() public LoginSuccessOrFailed: EventEmitter<LoginResult> = new EventEmitter();
    
      launchGoogleLogin() {
        this.authWindow = window.open(`${this.baseUrl}/api/Account/connect/Google`, null, 'width=600,height=400');
        this.isOpen = true;
        var timer = setInterval(() => {
          if (this.authWindow.closed) {
            this.isOpen = false;
            clearInterval(timer);
          }
        });
      }
    
      constructor(private authService: AuthService, private router: Router, @Inject('BASE_URL') private baseUrl: string) {
        if (window.addEventListener) {
          window.addEventListener("message", this.handleMessage.bind(this), false);
        } else {
          (<any>window).attachEvent("onmessage", this.handleMessage.bind(this));
        }
      }
    
      handleMessage(event: Event) {
        const message = event as MessageEvent;
        // Only trust messages from the below origin.
        if (message.origin !== "https://localhost:44385") return;
        // Filter out Augury
        if (message.data.messageSource != null)
          if (message.data.messageSource.indexOf("AUGURY_") > -1) return;
        // Filter out any other trash
        if (message.data == "") return;
    
        const result = <LoginResult>JSON.parse(message.data);
        if (result.platform == "Google") {
          this.authWindow.close();
          this.LoginSuccessOrFailed.emit(result);
        }
      }
    }
    

    auth.service.ts

    import { Injectable, Inject } from "@angular/core";
    import { HttpClient, HttpHeaders } from "@angular/common/http";
    import { RegistrationData } from "../helpers/registrationData";
    import { User } from "../entities/user";
    import { LoginResult } from "../entities/loginResult";
    
    @Injectable({
      providedIn: 'root'
    })
    
    export class AuthService {
      constructor(private httpClient: HttpClient, @Inject('BASE_URL') private baseUrl: string) {
      }
    
      public getToken() {
        return localStorage.getItem('auth_token');
      }
    
      public register(data: RegistrationData) {
        return this.httpClient.post(`${this.baseUrl}/api/account/register`, data);
      }
    
      public login(email: string, password: string) {
        return this.httpClient.post<LoginResult>(`${this.baseUrl}/api/account/login`, { email, password });
      }
    
      public logout() {
        return this.httpClient.post(`${this.baseUrl}/api/account/logout`, {});
      }
    
      public loginProviders() {
        return this.httpClient.get<string[]>(`${this.baseUrl}/api/account/providers`);
      }
    
      public currentUser() {
        return this.httpClient.get<User>(`${this.baseUrl}/api/account/current-user`);
      }
    }
    

    AccountController.cs

    [Route("api/[controller]")]
    public class AccountController : Controller
    {
        private IEmailSender emailSender;
        private IAccountRepository accountRepository;
        private IConfiguration configuration;
        private IAuthenticationSchemeProvider authenticationSchemeProvider;
        public AccountController(IConfiguration configuration, IEmailSender emailSender, IAuthenticationSchemeProvider authenticationSchemeProvider, IAccountRepository accountRepository)
        {
            this.configuration = configuration;
            this.emailSender = emailSender;
            this.accountRepository = accountRepository;
            this.authenticationSchemeProvider = authenticationSchemeProvider;
        }
    
        ...
    
        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody]LoginVM loginVM)
        {
            var login_result = await accountRepository.LocalLogin(loginVM.Email, loginVM.Password, true);
            return Ok(login_result);
        }
    
        [AllowAnonymous]
        [HttpGet("providers")]
        public async Task<List<string>> Providers()
        {
            var result = await authenticationSchemeProvider.GetRequestHandlerSchemesAsync();
            return result.Select(s => s.DisplayName).ToList();
        }
    
    
        [HttpGet("connect/{provider}")]
        [AllowAnonymous]
        public async Task<ActionResult> ExternalLogin(string provider, string returnUrl = null)
        {
            var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Account", new { provider });
            var properties = accountRepository.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
            return Challenge(properties, provider);
        }
    
        [HttpGet("connect/{provider}/callback")]
        public async Task<ActionResult> ExternalLoginCallback([FromRoute]string provider)
        {
            var model = new TokenMessageVM();
            try
            {
                var login_result = await accountRepository.PerfromExternalLogin();
                if(login_result.Status)
                {
                    model.AccessToken = login_result.Token;
                    model.Platform = login_result.Platform;
                    return View(model);
                }
                else
                {
                    model.Error = login_result.Error;
                    model.ErrorDescription = login_result.ErrorDescription;
                    model.Platform = login_result.Platform;
                    return View(model);
                }
            }
            catch (OtherAccountException other_account_ex)
            {
                model.Error = "Could not login";
                model.ErrorDescription = other_account_ex.Message;
                model.Platform = provider;
                return View(model);
            }
            catch (Exception ex)
            {
                model.Error = "Could not login";
                model.ErrorDescription = "There was an error with your social login";
                model.Platform = provider;
                return View(model);
            }
        }
    }
    

    Stuff that matters in the AccountRepository

    public interface IAccountRepository
    {
        ...
        Task<LoginResult> LocalLogin(string email, string password, bool remember);
        Task Logout();
    
        Task<User> GetUser(string id);
        Task<User> GetCurrentUser(ClaimsPrincipal userProperty);
        Task<List<User>> GetUsers();
    
        Microsoft.AspNetCore.Authentication.AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string redirectUrl);
        Task<LoginResult> PerfromExternalLogin();
    }
    

    Implementation

    public class AccountRepository : IAccountRepository
    {
        private YourDbContext your_db_context;
        private UserManager<Entities.User> user_manager;
        private SignInManager<Entities.User> signin_manager;
        private FacebookOptions facebookOptions;
        private JwtIssuerOptions jwtIssuerOptions;
        private IEmailSender email_sender;
        public AccountRepository(
            IEmailSender email_sender,
            UserManager<Entities.User> user_manager,
            SignInManager<Entities.User> signin_manager,
            IOptions<FacebookOptions> facebookOptions,
            IOptions<JwtIssuerOptions> jwtIssuerOptions,
            YourDbContext your_db_context)
        {
            this.user_manager = user_manager;
            this.signin_manager = signin_manager;
            this.email_sender = email_sender;
            this.your_db_context = your_db_context;
            this.facebookOptions = facebookOptions.Value;
            this.jwtIssuerOptions = jwtIssuerOptions.Value;
        }
    
        ...
    
        public async Task<LoginResult> LocalLogin(string email, string password, bool remember)
        {
            var user = await user_manager.FindByEmailAsync(email);
            var result = await signin_manager.PasswordSignInAsync(user, password, remember, false);
            if (result.Succeeded)
            {
                return new LoginResult {
                    Status = true,
                    Platform = "local",
                    User = ToDto(user),
                    Token = CreateToken(email)
                };
            }
            else
            {
                return new LoginResult {
                    Status = false,
                    Platform = "local",
                    Error = "Login attempt failed",
                    ErrorDescription = "Username or password incorrect"
                };
            }
        }
    
        public async Task Logout()
        {
            await signin_manager.SignOutAsync();
        }
    
        private string CreateToken(string email)
        {
            var token_descriptor = new SecurityTokenDescriptor
            {
                Issuer = jwtIssuerOptions.Issuer,
                IssuedAt = jwtIssuerOptions.IssuedAt,
                Audience = jwtIssuerOptions.Audience,
                NotBefore = DateTime.UtcNow,
                Expires = DateTime.UtcNow.AddDays(7),
                Subject = new ClaimsIdentity(new[]
                {
                    new Claim(ClaimTypes.Name, email)
                }),
                SigningCredentials = jwtIssuerOptions.SigningCredentials
            };
            var token_handler = new JwtSecurityTokenHandler();
            var token = token_handler.CreateToken(token_descriptor);
            var str_token = token_handler.WriteToken(token);
            return str_token;
        }
        private string CreateToken(ExternalLoginInfo info)
        {
            var identity = (ClaimsIdentity)info.Principal.Identity;
    
            var token_descriptor = new SecurityTokenDescriptor
            {
                Issuer = jwtIssuerOptions.Issuer,
                IssuedAt = jwtIssuerOptions.IssuedAt,
                Audience = jwtIssuerOptions.Audience,
                NotBefore = DateTime.UtcNow,
                Expires = DateTime.UtcNow.AddDays(7),
                Subject = identity,
                SigningCredentials = jwtIssuerOptions.SigningCredentials
            };
            var token_handler = new JwtSecurityTokenHandler();
            var token = token_handler.CreateToken(token_descriptor);
            var str_token = token_handler.WriteToken(token);
            return str_token;
        }
    
        public Microsoft.AspNetCore.Authentication.AuthenticationProperties ConfigureExternalAuthenticationProperties(string provider, string redirectUrl)
        {
            var properties = signin_manager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
            return properties;
        }
    
        public async Task<LoginResult> PerfromExternalLogin()
        {
            var info = await signin_manager.GetExternalLoginInfoAsync();
            if (info == null)
                throw new UnauthorizedAccessException();
    
            var user = await user_manager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
            if(user == null)
            {
                string username = info.Principal.FindFirstValue(ClaimTypes.Name);
                string email = info.Principal.FindFirstValue(ClaimTypes.Email);
    
                var new_user = new Entities.User
                {
                    UserName = username,
                    FacebookId = null,
                    Email = email,
                    PictureUrl = null
                };
                var id_result = await user_manager.CreateAsync(new_user);
                if (!id_result.Succeeded)
                {
                    // User creation failed, probably because the email address is already present in the database
                    if (id_result.Errors.Any(e => e.Code == "DuplicateEmail"))
                    {
                        var existing = await user_manager.FindByEmailAsync(email);
                        var existing_logins = await user_manager.GetLoginsAsync(existing);
    
                        if (existing_logins.Any())
                        {
                            throw new OtherAccountException(existing_logins);
                        }
                        else
                        {
                            throw new Exception("Could not create account from social profile");
                        }
                    }
                }
                await user_manager.AddLoginAsync(user, new UserLoginInfo(info.LoginProvider, info.ProviderKey, info.ProviderDisplayName));
                user = new_user;
            }
    
            var result = await signin_manager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
            if (result.Succeeded)
            {
                return new LoginResult {
                    Status = true,
                    Platform = info.LoginProvider,
                    User = ToDto(user),
                    Token = CreateToken(info)
                };
            }
            else if (result.IsLockedOut)
            {
                throw new UnauthorizedAccessException();
            }
            else
            {
                throw new UnauthorizedAccessException();
            }
        }
    }
    

    And finally the view that handles the callback and sends the message back to the main browser window (Views/Account/ExternalLoginCallback)

    @model Project.Web.ViewModels.Account.TokenMessageVM
    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Bezig met verwerken...</title>
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <script src="/util/util.js"></script>
    </head>
    <body>
        <script>
            // if we don't receive an access token then login failed and/or the user has not connected properly
            var accessToken = "@Model.AccessToken";
            var message = {};
            if (accessToken) {
                message.status = true;
                message.platform = "@Model.Platform";
    
                message.token = accessToken;
            } else {
                message.status = false;
                message.platform = "@Model.Platform";
    
                message.error = "@Model.Error";
                message.errorDescription = "@Model.ErrorDescription";
            }
            window.opener.postMessage(JSON.stringify(message), "https://localhost:44385");
        </script>
    </body>
    </html>
    

    ViewModel:

    public class TokenMessageVM
    {
        public string AccessToken { get; set; }
        public string Platform { get; set; }
    
        public string Error { get; set; }
        public string ErrorDescription { get; set; }
    }
    

    Startup.cs

    public void ConfigureServices(IServiceCollection services)
    {
        var connection_string = @"Server=(localdb)\mssqllocaldb;Database=DbName;Trusted_Connection=True;ConnectRetryCount=0";
            services
                .AddDbContext<YourDbContext>(
                    options => options.UseSqlServer(connection_string, b => b.MigrationsAssembly("EntitiesProjectAssembly"))
                )
    
        var connection_string = @"Server=(localdb)\mssqllocaldb;Database=DbName;Trusted_Connection=True;ConnectRetryCount=0";
    
        var app_settings = new Data.Helpers.JwtIssuerOptions();
        Configuration.GetSection(nameof(Data.Helpers.JwtIssuerOptions)).Bind(app_settings);
    
        services
            .AddDbContext<YourDbContext>(
                options => options.UseSqlServer(connection_string, b => b.MigrationsAssembly("EntitiesProjectAssembly"))
            )
            .AddScoped<IAccountRepository, AccountRepository>()
            .AddTransient<IEmailSender, EmailSender>()
            .AddMvc()
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    
        services
            .AddIdentity<Data.Entities.User, Data.Entities.Role>()
            .AddEntityFrameworkStores<YourDbContext>()
            .AddDefaultTokenProviders();
    
        services.AddDataProtection();
        services.Configure<IdentityOptions>(options =>
        {
            // Password settings
            options.Password.RequireDigit = true;
            options.Password.RequiredLength = 8;
            options.Password.RequireNonAlphanumeric = false;
            options.Password.RequireUppercase = true;
            options.Password.RequireLowercase = false;
            options.Password.RequiredUniqueChars = 6;
    
            // Lockout settings
            options.Lockout.DefaultLockoutTimeSpan = System.TimeSpan.FromMinutes(30);
            options.Lockout.MaxFailedAccessAttempts = 10;
            options.Lockout.AllowedForNewUsers = true;
    
            // User settings
            options.User.RequireUniqueEmail = true;
            options.User.AllowedUserNameCharacters = string.Empty;
        })
        .Configure<Data.Helpers.JwtIssuerOptions>(options =>
        {
            options.Issuer = app_settings.Issuer;
            options.Audience = app_settings.Audience;
            options.SigningCredentials = app_settings.SigningCredentials;
        })
        .ConfigureApplicationCookie(options =>
        {
            // Cookie settings
            options.Cookie.HttpOnly = true;
            options.Cookie.Expiration = System.TimeSpan.FromDays(150);
            // If the LoginPath isn't set, ASP.NET Core defaults 
            // the path to /Account/Login.
            options.LoginPath = "/Account/Login";
            // If the AccessDeniedPath isn't set, ASP.NET Core defaults 
            // the path to /Account/AccessDenied.
            options.AccessDeniedPath = "/Account/AccessDenied";
            options.SlidingExpiration = true;
        });
    
        services.AddAuthentication()
            .AddFacebook(options => {
                options.AppId = Configuration["FacebookAuthSettings:AppId"];
                options.AppSecret = Configuration["FacebookAuthSettings:AppSecret"];
            })
            .AddMicrosoftAccount(options => {
                options.ClientId = Configuration["MicrosoftAuthSettings:AppId"];
                options.ClientSecret = Configuration["MicrosoftAuthSettings:AppSecret"];
            })
            .AddGoogle(options => {
                options.ClientId = Configuration["GoogleAuthSettings:AppId"];
                options.ClientSecret = Configuration["GoogleAuthSettings:AppSecret"];
            })
            .AddTwitter(options => {
                options.ConsumerKey = Configuration["TwitterAuthSettings:ApiKey"];
                options.ConsumerSecret = Configuration["TwitterAuthSettings:ApiSecret"];
                options.RetrieveUserDetails = true;
            })
            .AddLinkedin(options => {
                options.ClientId = Configuration["LinkedInAuthSettings:AppId"];
                options.ClientSecret = Configuration["LinkedInAuthSettings:AppSecret"];
            })
            .AddGitHub(options => {
                options.ClientId = Configuration["GitHubAuthSettings:AppId"];
                options.ClientSecret = Configuration["GitHubAuthSettings:AppSecret"];
            })
            .AddPinterest(options => {
                options.ClientId = Configuration["PinterestAuthSettings:AppId"];
                options.ClientSecret = Configuration["PinterestAuthSettings:AppSecret"];
            });
    
        ...
    }
    

    It's also worth mentioning that you have to get permissions from the social-media sites: