diff --git a/src/Squidex.Infrastructure/Security/ExtendedClaimTypes.cs b/src/Squidex.Infrastructure/Security/ExtendedClaimTypes.cs new file mode 100644 index 000000000..c800cdaef --- /dev/null +++ b/src/Squidex.Infrastructure/Security/ExtendedClaimTypes.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// ExtendedClaimTypes.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Infrastructure.Security +{ + public class ExtendedClaimTypes + { + public const string SquidexDisplayName = "urn:squidex:name"; + + public const string SquidexPictureUrl = "urn:squidex:picture"; + } +} diff --git a/src/Squidex/.sass-lint.yml b/src/Squidex/.sass-lint.yml new file mode 100644 index 000000000..8c0a3baff --- /dev/null +++ b/src/Squidex/.sass-lint.yml @@ -0,0 +1,19 @@ +rules: + no-ids: + - 0 + final-newline: + - 0 + clean-import-paths: + - 0 + property-sort-order: + - 0 + indentation: + - 2 + - + size: 4 + +leading-underscore: false + +files: + ignore: + - 'app/theme/_mixins.scss' \ No newline at end of file diff --git a/src/Squidex/Configurations/Constants.cs b/src/Squidex/Configurations/Constants.cs index 3a586642b..f12824017 100644 --- a/src/Squidex/Configurations/Constants.cs +++ b/src/Squidex/Configurations/Constants.cs @@ -13,6 +13,8 @@ namespace Squidex.Configurations public const string ApiScope = "squidex-api"; + public const string ProfileScope = "squidex-profile"; + public const string FrontendClient = "squidex-frontend"; public const string IdentityPrefix = "/identity-server"; diff --git a/src/Squidex/Configurations/Identity/IdentityServices.cs b/src/Squidex/Configurations/Identity/IdentityServices.cs index 2b759c8f6..493424a91 100644 --- a/src/Squidex/Configurations/Identity/IdentityServices.cs +++ b/src/Squidex/Configurations/Identity/IdentityServices.cs @@ -15,6 +15,7 @@ using IdentityServer4.Stores.InMemory; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity.MongoDB; using Microsoft.Extensions.DependencyInjection; +using Squidex.Infrastructure.Security; namespace Squidex.Configurations.Identity { @@ -70,6 +71,15 @@ namespace Squidex.Configurations.Identity StandardScopes.OpenId, StandardScopes.Profile, new Scope + { + Name = Constants.ProfileScope, Type = ScopeType.Identity, + Claims = new List + { + new ScopeClaim(ExtendedClaimTypes.SquidexDisplayName, true), + new ScopeClaim(ExtendedClaimTypes.SquidexPictureUrl, true) + } + }, + new Scope { Name = Constants.ApiScope, Type = ScopeType.Resource } diff --git a/src/Squidex/Configurations/Identity/IdentityUsage.cs b/src/Squidex/Configurations/Identity/IdentityUsage.cs index 004a742ab..d2a6a2656 100644 --- a/src/Squidex/Configurations/Identity/IdentityUsage.cs +++ b/src/Squidex/Configurations/Identity/IdentityUsage.cs @@ -6,13 +6,21 @@ // All rights reserved. // ========================================================================== +using System; +using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Security.Claims; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.MongoDB; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Infrastructure.Security; // ReSharper disable InvertIf @@ -68,6 +76,7 @@ namespace Squidex.Configurations.Identity var googleOptions = new GoogleOptions { + Events = new GoogleHandler(), ClientId = options.GoogleClient, ClientSecret = options.GoogleSecret }; @@ -98,5 +107,52 @@ namespace Squidex.Configurations.Identity return app; } + + private class RetrieveClaimsHandler : OAuthEvents + { + public override Task CreatingTicket(OAuthCreatingTicketContext context) + { + var displayNameClaim = context.Identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Name); + if (displayNameClaim != null) + { + context.Identity.AddClaim(new Claim(ExtendedClaimTypes.SquidexDisplayName, displayNameClaim.Value)); + } + + return base.CreatingTicket(context); + } + } + + private sealed class GoogleHandler : RetrieveClaimsHandler + { + private static readonly HttpClient HttpClient = new HttpClient(); + + public override Task RedirectToAuthorizationEndpoint(OAuthRedirectToAuthorizationContext context) + { + context.Response.Redirect(context.RedirectUri + "&prompt=select_account"); + + return Task.FromResult(true); + } + + public override async Task CreatingTicket(OAuthCreatingTicketContext context) + { + if (!string.IsNullOrWhiteSpace(context.AccessToken)) + { + var apiRequestUri = new Uri($"https://www.googleapis.com/oauth2/v2/userinfo?access_token={context.AccessToken}"); + + var jsonReponseString = + await HttpClient.GetStringAsync(apiRequestUri); + var jsonResponse = JToken.Parse(jsonReponseString); + + var pictureUrl = jsonResponse["picture"]?.Value(); + + if (!string.IsNullOrWhiteSpace(pictureUrl)) + { + context.Identity.AddClaim(new Claim(ExtendedClaimTypes.SquidexPictureUrl, pictureUrl)); + } + } + + await base.CreatingTicket(context); + } + } } } diff --git a/src/Squidex/Configurations/Identity/LazyClientStore.cs b/src/Squidex/Configurations/Identity/LazyClientStore.cs index 92aad733a..6eb6e5ff3 100644 --- a/src/Squidex/Configurations/Identity/LazyClientStore.cs +++ b/src/Squidex/Configurations/Identity/LazyClientStore.cs @@ -47,20 +47,22 @@ namespace Squidex.Configurations.Identity ClientName = id, RedirectUris = new List { - options.BuildUrl("#/login;"), - options.BuildUrl("#/logout;"), options.BuildUrl("login;"), - options.BuildUrl("logout;"), options.BuildUrl("identity-server/client-callback-silent/"), options.BuildUrl("identity-server/client-callback-popup/") }, + PostLogoutRedirectUris = new List + { + options.BuildUrl("logout;"), + }, AllowAccessTokensViaBrowser = true, AllowedGrantTypes = GrantTypes.Implicit, AllowedScopes = new List { StandardScopes.OpenId.Name, StandardScopes.Profile.Name, - Constants.ApiScope + Constants.ApiScope, + Constants.ProfileScope }, RequireConsent = false }; diff --git a/src/Squidex/Modules/UI/Account/AccountController.cs b/src/Squidex/Modules/UI/Account/AccountController.cs index 018d521fd..987e22a9f 100644 --- a/src/Squidex/Modules/UI/Account/AccountController.cs +++ b/src/Squidex/Modules/UI/Account/AccountController.cs @@ -9,10 +9,12 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using IdentityServer4.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.MongoDB; using Microsoft.AspNetCore.Mvc; +using Squidex.Infrastructure.Security; // ReSharper disable RedundantIfElseBlock // ReSharper disable ConvertIfStatementToReturnStatement @@ -23,11 +25,16 @@ namespace Squidex.Modules.UI.Account { private readonly SignInManager signInManager; private readonly UserManager userManager; + private readonly IIdentityServerInteractionService interactions; - public AccountController(SignInManager signInManager, UserManager userManager) + public AccountController( + SignInManager signInManager, + UserManager userManager, + IIdentityServerInteractionService interactions) { this.signInManager = signInManager; this.userManager = userManager; + this.interactions = interactions; } [Authorize] @@ -59,6 +66,17 @@ namespace Squidex.Modules.UI.Account return View(); } + [HttpGet] + [Route("account/logout/")] + public async Task Logout(string logoutId) + { + var context = await interactions.GetLogoutContextAsync(logoutId); + + await signInManager.SignOutAsync(); + + return context.PostLogoutRedirectUri != null ? (IActionResult)Redirect(context.PostLogoutRedirectUri) : StatusCode(201); + } + [HttpGet] [Route("account/login/")] public IActionResult Login(string returnUrl = null) @@ -144,7 +162,21 @@ namespace Squidex.Modules.UI.Account { var mail = externalLogin.Principal.FindFirst(ClaimTypes.Email).Value; - return new IdentityUser { Email = mail, UserName = mail }; + var user = new IdentityUser { Email = mail, UserName = mail }; + + var profileUrl = externalLogin.Principal.Claims.FirstOrDefault(x => x.Type == ExtendedClaimTypes.SquidexPictureUrl); + if (profileUrl != null) + { + user.AddClaim(profileUrl); + } + + var displayName = externalLogin.Principal.Claims.FirstOrDefault(x => x.Type == ExtendedClaimTypes.SquidexDisplayName); + if (displayName != null) + { + user.AddClaim(displayName); + } + + return user; } } } diff --git a/src/Squidex/Properties/launchSettings.json b/src/Squidex/Properties/launchSettings.json index 99dcec991..ba7b5eacf 100644 --- a/src/Squidex/Properties/launchSettings.json +++ b/src/Squidex/Properties/launchSettings.json @@ -10,7 +10,6 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Squidex/app/app.module.ts b/src/Squidex/app/app.module.ts index a0ddf3118..277e079d3 100644 --- a/src/Squidex/app/app.module.ts +++ b/src/Squidex/app/app.module.ts @@ -14,12 +14,13 @@ import { ApiUrlConfig, AppsStoreService, AppsService, - AuthGuard, AuthService, CurrencyConfig, DragService, DragServiceFactory, DecimalSeparatorConfig, + MustBeAuthenticatedGuard, + MustBeNotAuthenticatedGuard, SqxFrameworkModule, TitlesConfig, TitleService @@ -27,8 +28,8 @@ import { import { SqxAppModule, + SqxAuthModule, SqxLayoutModule, - SqxLoginModule, SqxPublicModule } from './components'; @@ -40,8 +41,8 @@ const baseUrl = window.location.protocol + '//' + window.location.host + '/'; imports: [ Ng2Browser.BrowserModule, SqxAppModule, + SqxAuthModule, SqxLayoutModule, - SqxLoginModule, SqxFrameworkModule, SqxPublicModule, routing @@ -52,8 +53,9 @@ const baseUrl = window.location.protocol + '//' + window.location.host + '/'; providers: [ AppsStoreService, AppsService, - AuthGuard, AuthService, + MustBeAuthenticatedGuard, + MustBeNotAuthenticatedGuard, TitleService, { provide: ApiUrlConfig, useValue: new ApiUrlConfig(baseUrl) }, { provide: CurrencyConfig, useValue: new CurrencyConfig('EUR', '€', true) }, diff --git a/src/Squidex/app/app.routes.ts b/src/Squidex/app/app.routes.ts index dcc4bf44a..8ef5dec10 100644 --- a/src/Squidex/app/app.routes.ts +++ b/src/Squidex/app/app.routes.ts @@ -13,24 +13,26 @@ import { AppAreaComponent, DashboardComponent, InternalAreaComponent, - LoginPageComponent, + HomePageComponent, LogoutPageComponent, NotFoundPageComponent } from './components'; import { - AuthGuard + MustBeAuthenticatedGuard, + MustBeNotAuthenticatedGuard } from './shared'; export const routes: Ng2Router.Routes = [ { path: '', - redirectTo: 'app', pathMatch: 'full' + component: HomePageComponent, + canActivate: [MustBeNotAuthenticatedGuard], }, { path: 'app', component: InternalAreaComponent, - canActivate: [AuthGuard], + canActivate: [MustBeAuthenticatedGuard], children: [ { path: '', @@ -48,10 +50,6 @@ export const routes: Ng2Router.Routes = [ } ] }, - { - path: 'login', - component: LoginPageComponent - }, { path: 'logout', component: LogoutPageComponent diff --git a/src/Squidex/app/components/auth/declarations.ts b/src/Squidex/app/components/auth/declarations.ts index 59e51248c..aef7b3a4a 100644 --- a/src/Squidex/app/components/auth/declarations.ts +++ b/src/Squidex/app/components/auth/declarations.ts @@ -5,5 +5,4 @@ * Copyright (c) Sebastian Stehle. All rights reserved */ -export * from './login-page.component'; export * from './logout-page.component'; \ No newline at end of file diff --git a/src/Squidex/app/components/auth/login-page.component.html b/src/Squidex/app/components/auth/login-page.component.html deleted file mode 100644 index 8d2d3f655..000000000 --- a/src/Squidex/app/components/auth/login-page.component.html +++ /dev/null @@ -1,5 +0,0 @@ -
-
- Failed to login -
-
\ No newline at end of file diff --git a/src/Squidex/app/components/auth/logout-page.component.html b/src/Squidex/app/components/auth/logout-page.component.html deleted file mode 100644 index 8d2d3f655..000000000 --- a/src/Squidex/app/components/auth/logout-page.component.html +++ /dev/null @@ -1,5 +0,0 @@ -
-
- Failed to login -
-
\ No newline at end of file diff --git a/src/Squidex/app/components/auth/logout-page.component.ts b/src/Squidex/app/components/auth/logout-page.component.ts index a4deb1536..344ad62fc 100644 --- a/src/Squidex/app/components/auth/logout-page.component.ts +++ b/src/Squidex/app/components/auth/logout-page.component.ts @@ -12,17 +12,17 @@ import { AuthService } from 'shared'; @Ng2.Component({ selector: 'logout', - template + template: '' }) export class LogoutPageComponent implements Ng2.OnInit { constructor( - private readonly authService: AuthService, + private readonly auth: AuthService, private readonly router: Ng2Router.Router ) { } public ngOnInit() { - this.authService.logoutComplete().subscribe( + this.auth.logoutComplete().subscribe( () => { this.router.navigate(['/'], { replaceUrl: true }); }, diff --git a/src/Squidex/app/components/auth/module.ts b/src/Squidex/app/components/auth/module.ts index 5ce195873..1ebdabaca 100644 --- a/src/Squidex/app/components/auth/module.ts +++ b/src/Squidex/app/components/auth/module.ts @@ -10,7 +10,6 @@ import * as Ng2 from '@angular/core'; import { SqxFrameworkModule } from 'shared'; import { - LoginPageComponent, LogoutPageComponent } from './declarations'; @@ -19,8 +18,7 @@ import { SqxFrameworkModule ], declarations: [ - LoginPageComponent, LogoutPageComponent ] }) -export class SqxLoginModule { } \ No newline at end of file +export class SqxAuthModule { } \ No newline at end of file diff --git a/src/Squidex/app/components/internal/apps/apps-page.component.html b/src/Squidex/app/components/internal/apps/apps-page.component.html index 775fabb59..60cd45af8 100644 --- a/src/Squidex/app/components/internal/apps/apps-page.component.html +++ b/src/Squidex/app/components/internal/apps/apps-page.component.html @@ -6,7 +6,7 @@ -