Working version with Cloudflare Access JWT

This commit is contained in:
Coby Geralnik 2021-10-17 23:27:25 +03:00
commit 5658f84613
15 changed files with 325 additions and 57 deletions

View file

@ -52,7 +52,7 @@ namespace Ombi.Core.Authentication
IdentityErrorDescriber errors, IServiceProvider services, ILogger<UserManager<OmbiUser>> logger, IPlexApi plexApi,
IEmbyApiFactory embyApi, ISettingsService<EmbySettings> embySettings,
IJellyfinApiFactory jellyfinApi, ISettingsService<JellyfinSettings> jellyfinSettings,
ISettingsService<AuthenticationSettings> auth)
ISettingsService<AuthenticationSettings> auth, ISettingsService<CloudflareAuthenticationSettings> cfauth)
: base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger)
{
_plexApi = plexApi;

View file

@ -13,5 +13,6 @@ namespace Ombi.Settings.Settings.Models
public bool RequireNonAlphanumeric { get; set; }
public bool RequireUppercase { get; set; }
public bool EnableOAuth { get; set; } // Plex OAuth
public bool EnableCloudflareAccess { get; set; }
}
}

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Ombi.Settings.Settings.Models
{
public class CloudflareAuthenticationSettings : Settings
{
public string issuer { get; set; }
public string audience { get; set; }
public string certlink { get; set; }
}
}

View file

@ -28,6 +28,10 @@ export class AuthService extends ServiceHelpers {
return this.http.post<boolean>(`${this.url}/requirePassword`, JSON.stringify(login), { headers: this.headers });
}
public attemptCF(): Observable<any> {
return this.http.get<any>(`${this.url}/cfAuth`);
}
public getToken() {
return this.jwtHelperService.tokenGetter();
}

View file

@ -230,6 +230,13 @@ export interface IAuthenticationSettings extends ISettings {
requireNonAlphanumeric: boolean;
requireUppercase: boolean;
enableOAuth: boolean;
enableCloudflareAccess: boolean;
}
export interface ICloudflareSettings extends ISettings {
issuer: string;
audience: string;
certlink: string;
}
export interface ICustomPage extends ISettings {

View file

@ -9,7 +9,7 @@
<H1 *ngIf="!customizationSettings.logo && !customizationSettings.applicationName" class="login_logo">OMBI</H1>
<H1 *ngIf="customizationSettings.applicationName && !customizationSettings.logo" [ngClass]="{'bigText': customizationSettings.applicationName.length >= 7 && customizationSettings.applicationName.length < 14, 'hugeText': customizationSettings.applicationName.length >= 14 }" class="login_logo custom">{{customizationSettings.applicationName}}</H1>
<img mat-card-image *ngIf="customizationSettings.logo" [src]="customizationSettings.logo" class="logo-img">
<mat-card-content id="login-box" *ngIf="!authenticationSettings.enableOAuth || loginWithOmbi">
<mat-card-content id="login-box" *ngIf="!(authenticationSettings.enableOAuth||authenticationSettings.enableCloudflareAccess) || loginWithOmbi">
<form *ngIf="authenticationSettings" class="form-signin" novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
@ -35,11 +35,15 @@
</form>
</mat-card-content>
<mat-card-content *ngIf="authenticationSettings.enableOAuth && !loginWithOmbi" class="login-buttons">
<mat-card-content *ngIf="!loginWithOmbi" class="login-buttons">
<div>
<button id="sign-in" mat-raised-button color="primary" type="submit" data-cy="OmbiButton" (click)="loginWithOmbi = true">{{'Login.SignInWith' | translate:appNameTranslate}}</button>
<button id="sign-plex" mat-raised-button color="accent" type="button" data-cy="oAuthPlexButton" (click)="oauth()">
<button id="sign-cf" *ngIf="authenticationSettings.enableCloudflareAccess" mat-raised-button color="primary" type="button" data-cy="CfButton" (click)="cfauth()">
<span *ngIf="!cfLoading">{{'Login.SignInWithCF' | translate}}</span>
<span *ngIf="cfLoading"><i class="fas fa-circle-notch fa-spin fa-fw"></i></span>
</button>
<button id="sign-plex" *ngIf="authenticationSettings.enableOAuth" mat-raised-button color="accent" type="button" data-cy="oAuthPlexButton" (click)="oauth()">
<span *ngIf="!oauthLoading">{{'Login.SignInWithPlex' | translate}}</span>
<span *ngIf="oauthLoading"><i class="fas fa-circle-notch fa-spin fa-fw"></i></span>
</button>

View file

@ -34,6 +34,7 @@ export class LoginComponent implements OnDestroy, OnInit {
public loginWithOmbi: boolean;
public pinTimer: any;
public oauthLoading: boolean;
public cfLoading: boolean;
public get appName(): string {
if (this.customizationSettings.applicationName) {
@ -71,6 +72,7 @@ export class LoginComponent implements OnDestroy, OnInit {
private store: StorageService,
private readonly notify: MatSnackBar
) {
this.href = href;
this.route.params.subscribe((params: any) => {
this.landingFlag = params.landing;
@ -101,7 +103,6 @@ export class LoginComponent implements OnDestroy, OnInit {
}
public ngOnInit() {
this.customziationFacade.settings$().subscribe(x => this.customizationSettings = x);
this.settingsService
@ -173,6 +174,34 @@ export class LoginComponent implements OnDestroy, OnInit {
});
}
public cfauth() {
this.cfLoading = true;
this.translate
.get("Login.Errors.UnauthorizedCFAccount")
.subscribe((x) => (this.errorBody = x));
this.authService.attemptCF().subscribe(
(x) => {
this.store.save("id_token", x.access_token);
if (this.authService.loggedIn()) {
this.ngOnDestroy();
this.router.navigate(["/"]);
} else {
this.notify.open(this.errorBody, "OK", {
duration: 3000,
});
}
},
(err) => {
this.notify.open(this.errorBody, "OK", {
duration: 3000000,
});
}
);
this.cfLoading = false;
}
public oauth() {
if (this.oAuthWindow) {
this.oAuthWindow.close();

View file

@ -6,6 +6,7 @@ import { Observable } from "rxjs";
import {
IAbout,
IAuthenticationSettings,
ICloudflareSettings,
ICouchPotatoSettings,
ICronTestModel,
ICronViewModelBody,
@ -125,6 +126,10 @@ export class SettingsService extends ServiceHelpers {
return this.http.get<IAuthenticationSettings>(`${this.url}/Authentication`, {headers: this.headers});
}
public getCloudflareAuthentication(): Observable<ICloudflareSettings> {
return this.http.get<ICloudflareSettings>(`${this.url}/Cloudflare`, {headers: this.headers});
}
public getClientId(): Observable<string> {
return this.http.get<string>(`${this.url}/clientid`, {headers: this.headers});
}
@ -133,6 +138,10 @@ export class SettingsService extends ServiceHelpers {
return this.http.post<boolean>(`${this.url}/Authentication`, JSON.stringify(settings), {headers: this.headers});
}
public saveCloudflareAuthentication(settings: ICloudflareSettings): Observable<boolean> {
return this.http.post<boolean>(`${this.url}/Cloudflare`, JSON.stringify(settings), {headers: this.headers});
}
// Using http since we need it not to be authenticated to get the landing page settings
public getLandingPage(): Observable<ILandingPageSettings> {
return this.http.get<ILandingPageSettings>(`${this.url}/LandingPage`, {headers: this.headers});

View file

@ -1,66 +1,111 @@
<settings-menu></settings-menu>
<div class="small-middle-container">
<wiki></wiki>
<fieldset *ngIf="form">
<legend>Authentication</legend>
<div class="md-form-field" style="margin-top:1em;"></div>
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group">
<div class="checkbox">
<mat-slide-toggle id="allowNoPassword" name="allowNoPassword" formControlName="allowNoPassword">
Allow users to login without a password</mat-slide-toggle>
<wiki></wiki>
<fieldset *ngIf="form">
<legend>Authentication</legend>
<div class="md-form-field" style="margin-top:1em;"></div>
<form novalidate [formGroup]="form" (ngSubmit)="onSubmit(form)">
<div class="form-group">
<div class="checkbox">
<mat-slide-toggle id="allowNoPassword" name="allowNoPassword" formControlName="allowNoPassword">
Allow users to login without a password</mat-slide-toggle>
</div>
</div>
</div>
<div class="form-group">
<div class="checkbox">
<mat-slide-toggle id="enableOAuth" name="enableOAuth" formControlName="enableOAuth">Enable Plex OAuth</mat-slide-toggle>
<div class="form-group">
<div class="checkbox">
<mat-slide-toggle id="enableOAuth" name="enableOAuth" formControlName="enableOAuth">Enable Plex OAuth</mat-slide-toggle>
</div>
</div>
</div>
<!-- <hr/>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requiredDigit" name="requiredDigit" formControlName="requiredDigit">
<label for="requiredDigit">Require a digit in the password</label>
<div class="form-group">
<div class="checkbox">
<mat-slide-toggle id="enableCloudflareAccess" name="enableCloudflareAccess" formControlName="enableCloudflareAccess">Enable Cloudflare Access Auth</mat-slide-toggle>
</div>
</div>
</div>
<div class="form-group">
<label for="requiredLength" class="control-label">Required password length</label>
<div>
<input type="text" class="form-control form-control-custom " id="requiredLength" name="requiredLength" formControlName="requiredLength">
</div>
</div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requiredLowercase" name="requiredLowercase" formControlName="requiredLowercase">
<label for="requiredLowercase">Require a lowercase character in the password</label>
<!-- <hr/>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requiredDigit" name="requiredDigit" formControlName="requiredDigit">
<label for="requiredDigit">Require a digit in the password</label>
</div>
</div>
</div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requireNonAlphanumeric" name="requireNonAlphanumeric" formControlName="requireNonAlphanumeric">
<label for="requireNonAlphanumeric">Require a NonAlphanumeric character in the password</label>
<div class="form-group">
<label for="requiredLength" class="control-label">Required password length</label>
<div>
<input type="text" class="form-control form-control-custom " id="requiredLength" name="requiredLength" formControlName="requiredLength">
</div>
</div>
</div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requireUppercase" name="requireUppercase" formControlName="requireUppercase">
<label for="requireUppercase">Require a uppercase character in the password</label>
</div>
</div> -->
<div class="form-group">
<div>
<button mat-raised-button type="submit" color="primary" [disabled]="form.invalid" class="mat-focus-indicator mat-stroked-button accent mat-accent mat-raised-button mat-button-base" ng-reflect-disabled="false">
<span class="mat-button-wrapper">Submit</span><div matripple="" class="mat-ripple mat-button-ripple" ng-reflect-disabled="false" ng-reflect-centered="false" ng-reflect-trigger="[object HTMLButtonElement]"></div>
<div class="mat-button-focus-overlay"></div></button><div class="md-form-field" style="margin-top:1em;"></div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requiredLowercase" name="requiredLowercase" formControlName="requiredLowercase">
<label for="requiredLowercase">Require a lowercase character in the password</label>
</div>
</div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requireNonAlphanumeric" name="requireNonAlphanumeric" formControlName="requireNonAlphanumeric">
<label for="requireNonAlphanumeric">Require a NonAlphanumeric character in the password</label>
</div>
</div>
<div class="form-group">
<div class="checkbox">
<input type="checkbox" id="requireUppercase" name="requireUppercase" formControlName="requireUppercase">
<label for="requireUppercase">Require a uppercase character in the password</label>
</div>
</div> -->
<div class="form-group">
<div>
<button mat-raised-button type="submit" color="primary" [disabled]="form.invalid" class="mat-focus-indicator mat-stroked-button accent mat-accent mat-raised-button mat-button-base" ng-reflect-disabled="false">
<span class="mat-button-wrapper">Submit</span><div matripple="" class="mat-ripple mat-button-ripple" ng-reflect-disabled="false" ng-reflect-centered="false" ng-reflect-trigger="[object HTMLButtonElement]"></div>
<div class="mat-button-focus-overlay"></div></button><div class="md-form-field" style="margin-top:1em;"></div>
</div>
</div>
</form>
</fieldset>
<fieldset *ngIf="cfform">
<hr>
<br>
<div>
<legend>Cloudflare Authentication</legend>
<div class="md-form-field" style="margin-top:1em;"></div>
<form novalidate [formGroup]="cfform" (ngSubmit)="oncfSubmit(cfform)">
<div class="form-group">
<mat-form-field appearance="fill">
<mat-label>Audience TAG</mat-label>
<input matInput id="audience" name="audience" formControlName="audience">
</mat-form-field>
</div>
<div class="form-group">
<mat-form-field appearance="fill">
<mat-label>JWT Certificate URL</mat-label>
<input matInput id="certlink" name="certlink" formControlName="certlink">
</mat-form-field>
</div>
<div class="form-group">
<mat-form-field appearance="fill">
<mat-label>Issuer URL</mat-label>
<input matInput id="issuer" name="issuer" formControlName="issuer">
</mat-form-field>
</div>
<div class="form-group">
<div>
<button mat-raised-button type="submit" color="primary" [disabled]="form.invalid" class="mat-focus-indicator mat-stroked-button accent mat-accent mat-raised-button mat-button-base" ng-reflect-disabled="false">
<span class="mat-button-wrapper">Submit</span><div matripple="" class="mat-ripple mat-button-ripple" ng-reflect-disabled="false" ng-reflect-centered="false" ng-reflect-trigger="[object HTMLButtonElement]"></div>
<div class="mat-button-focus-overlay"></div></button><div class="md-form-field" style="margin-top:1em;"></div>
</div>
</div>
</form>
</div>
</form>
</fieldset>
</fieldset>
</div>

View file

@ -11,6 +11,7 @@ import { SettingsService } from "../../services";
export class AuthenticationComponent implements OnInit {
public form: FormGroup;
public cfform: FormGroup;
constructor(private settingsService: SettingsService,
private notificationService: NotificationService,
@ -26,6 +27,14 @@ export class AuthenticationComponent implements OnInit {
requireNonAlphanumeric: [x.requireNonAlphanumeric],
requireUppercase: [x.requireUppercase],
enableOAuth: [x.enableOAuth],
enableCloudflareAccess: [x.enableCloudflareAccess],
});
});
this.settingsService.getCloudflareAuthentication().subscribe(x => {
this.cfform = this.fb.group({
audience: [x.audience],
certlink: [x.certlink],
issuer: [x.issuer],
});
});
}
@ -44,4 +53,19 @@ export class AuthenticationComponent implements OnInit {
}
});
}
public oncfSubmit(form: FormGroup) {
if (form.invalid) {
this.notificationService.error("Please check your entered values");
return;
}
this.settingsService.saveCloudflareAuthentication(form.value).subscribe(x => {
if (x) {
this.notificationService.success("Successfully saved Cloudflare Authentication settings");
} else {
this.notificationService.success("There was an error when saving the Cloudflare Authentication settings");
}
});
}
}

View file

@ -45,6 +45,10 @@ td.mat-cell {
background-color: $ombi-active;
color: $ombi-active-text;
}
&#sign-cf{
background-color: #282A2D;
color: #E5A00D;
}
&#sign-plex{
background-color: #282A2D;
color: #E5A00D;

View file

@ -467,6 +467,32 @@ namespace Ombi.Controllers.V1
return await Get<AuthenticationSettings>();
}
/// <summary>
/// Gets the Cloudflare Authentication Settings.
/// </summary>
/// <returns></returns>
[HttpGet("cloudflare")]
public async Task<CloudflareAuthenticationSettings> CloudflareAuthenticationsSettings()
{
return await Get<CloudflareAuthenticationSettings>();
}
/// <summary>
/// Save the Cloudflare Authentication Settings.
/// </summary>
/// <returns></returns>
[HttpPost("cloudflare")]
public async Task<bool> CloudflareAuthenticationsSettings([FromBody]CloudflareAuthenticationSettings settings)
{
if (settings.audience.IsNullOrEmpty() && settings.certlink.IsNullOrEmpty() && settings.issuer.IsNullOrEmpty()) {
return true;
}
if ((settings.audience.IsNullOrEmpty() || settings.certlink.IsNullOrEmpty() || settings.issuer.IsNullOrEmpty())) {
return false;
}
return await Save(settings);
}
/// <summary>
/// Save the Radarr settings.
/// </summary>

View file

@ -2,18 +2,23 @@
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Ombi.Core.Authentication;
using Ombi.Core.Settings;
using Ombi.Helpers;
using Ombi.Models;
using Ombi.Models.External;
using Ombi.Models.Identity;
using Ombi.Settings.Settings.Models;
using Ombi.Store.Entities;
using Ombi.Store.Repository;
@ -25,13 +30,17 @@ namespace Ombi.Controllers.V1
public class TokenController : ControllerBase
{
public TokenController(OmbiUserManager um, IOptions<TokenAuthentication> ta, ITokenRepository token,
IPlexOAuthManager oAuthManager, ILogger<TokenController> logger)
IPlexOAuthManager oAuthManager, ILogger<TokenController> logger,
ISettingsService<AuthenticationSettings> auth, ISettingsService<CloudflareAuthenticationSettings> cfauth)
{
_userManager = um;
_tokenAuthenticationOptions = ta.Value;
_token = token;
_plexOAuthManager = oAuthManager;
_log = logger;
_authSettings = auth;
_cfsettings = cfauth;
_ = updateCFKeys(true);
}
private readonly TokenAuthentication _tokenAuthenticationOptions;
@ -39,6 +48,10 @@ namespace Ombi.Controllers.V1
private readonly OmbiUserManager _userManager;
private readonly IPlexOAuthManager _plexOAuthManager;
private readonly ILogger<TokenController> _log;
private readonly ISettingsService<AuthenticationSettings> _authSettings;
private readonly ISettingsService<CloudflareAuthenticationSettings> _cfsettings;
private CloudflareJWTJson _cfJson;
/// <summary>
/// Gets the token.
@ -100,6 +113,7 @@ namespace Ombi.Controllers.V1
/// Returns the Token for the Ombi User if we can match the Plex user with a valid Ombi User
/// </summary>
[HttpPost("plextoken")]
[ProducesResponseType(200)]
[ProducesResponseType(401)]
[ProducesResponseType(400)]
public async Task<IActionResult> GetTokenWithPlexToken([FromBody] PlexTokenAuthentication model)
@ -117,6 +131,70 @@ namespace Ombi.Controllers.V1
}
/// <summary>
/// Returns the Token for the Ombi User if we can match the Plex user with a valid Ombi User
/// </summary>
[HttpGet("cfAuth")]
[ProducesResponseType(401)]
[ProducesResponseType(400)]
public async Task<IActionResult> GetTokenWithCFJwt([FromHeader(Name="Cf-Access-Jwt-Assertion")] string CFJWTAuthentication)
{
if (!_authSettings.GetSettings().EnableCloudflareAccess) {
_log.LogError("EnableCloudflareAccess is disabled, API won't work!");
return Unauthorized();
}
if (String.IsNullOrEmpty(CFJWTAuthentication)) {
_log.LogError("Cf-Access-Jwt-Assertion Not found!");
return Unauthorized();
}
updateCFKeys(false);
IList<JsonWebKey> jwkList = new List<JsonWebKey>();
foreach (Dictionary<string,string> key in _cfJson.keys) {
jwkList.Add(JsonWebKey.Create(JsonSerializer.Serialize(key)));
}
CloudflareAuthenticationSettings cfSettings = _cfsettings.GetSettings();
TokenValidationParameters validationParameters = new TokenValidationParameters
{
ValidIssuer = cfSettings.issuer,
ValidateIssuer = true,
ValidAudience = cfSettings.audience,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeys = jwkList,
TryAllIssuerSigningKeys = true
};
string email = null;
JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
try
{
ClaimsPrincipal claimsP = handler.ValidateToken(CFJWTAuthentication, validationParameters, out var validatedSecurityToken);
email = claimsP.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress").Value;
}
catch
{
return Unauthorized();
}
if (String.IsNullOrEmpty(email)) {
_log.LogError("cfJWT email not found!");
return Unauthorized();
}
OmbiUser user = await _userManager.FindByEmailAsync(email);
if (user == null)
{
return Unauthorized();
}
_log.LogInformation(String.Format("Logging in user {0} with cfJWT", email));
return await CreateToken(false, user);
}
private async Task<IActionResult> CreateToken(bool rememberMe, OmbiUser user)
{
var roles = await _userManager.GetRolesAsync(user);
@ -272,5 +350,16 @@ namespace Ombi.Controllers.V1
return ip;
}
private bool updateCFKeys(bool init) {
if (!(init || ((DateTime.UtcNow - _cfJson.lastUpdate).TotalDays > 7))) {
return false;
}
HttpClient client = new HttpClient();
Task<string> res = Task.Run<string>(async () => await client.GetStringAsync(_cfsettings.GetSettings().certlink));
_cfJson = JsonSerializer.Deserialize<CloudflareJWTJson>(res.Result);
return true;
}
}
}

View file

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
namespace Ombi.Models.External
{
public class CloudflareJWTJson
{
public IList<Dictionary<string, string>> keys { get; set; }
public Dictionary<string, string> public_cert { get; set; }
public IList<Dictionary<string, string>> public_certs { get; set; }
public DateTime lastUpdate { get; set; }
}
}

View file

@ -6,9 +6,11 @@
"RememberMe": "Remember Me",
"SignInWith": "Sign in with {{appName}}",
"SignInWithPlex": "Sign in with Plex",
"SignInWithCF": "Sign in with Cloudflare",
"ForgottenPassword": "Forgot your password?",
"Errors": {
"IncorrectCredentials": "Incorrect username or password"
"IncorrectCredentials": "Incorrect username or password",
"UnauthorizedCFAccount": "Ombi has no user with the same email as the Cloudflare account"
}
},
"Common": {