Merge pull request #111 from AnonymusRaccoon/jwt

This commit is contained in:
Zoe Roux 2022-05-10 20:45:34 +02:00 committed by GitHub
commit 7a8964c336
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 3128 additions and 3597 deletions

View File

@ -70,7 +70,6 @@ csharp_indent_case_contents = true
csharp_indent_switch_labels = true
# Modifiers
dotnet_style_readonly_field = true:suggestion
csharp_preferred_modifier_order = public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
# Naming style
dotnet_naming_symbols.privates.applicable_kinds = property,method,event,delegate

View File

@ -1,2 +1,3 @@
TVDB__APIKEY=
THEMOVIEDB__APIKEY=
AUTHENTICATION_SECRET=

1
.gitignore vendored
View File

@ -356,3 +356,4 @@ healthchecksdb
/Kyoo/TheTVDB-Credentials.json
.vscode
.netcoredbg_hist

View File

@ -10,7 +10,7 @@ title: Build
To develop for Kyoo, you will need the **.NET 5.0 SDK**, **node** and **npm** for the webapp. If you want to build the transcoder, you will also need a cmake compatible environment.
## Building
To run the development server, simply open the .sln file with your favorite C# IDE (like Jetbrain's Rider or Visual Studio) and press run or you can use the CLI and use the ```dotnet run dotnet run -p src/Kyoo.Host.Console --launch-profile "Console"``` command.
To run the development server, simply open the .sln file with your favorite C# IDE (like Jetbrain's Rider or Visual Studio) and press run or you can use the CLI and use the ```dotnet run --project src/Kyoo.Host.Console --launch-profile "Console"``` command.
To pack the application, run the ```dotnet publish -c Release -o <build_path> Kyoo.Host.Console``` command. This will build the server, the webapp and the transcoder and output files in the <build_path> directory.
## Skipping parts

View File

@ -26,7 +26,6 @@
"@angular/platform-browser": "^13.0.2",
"@angular/platform-browser-dynamic": "^13.0.2",
"@angular/router": "^13.0.2",
"angular-auth-oidc-client": "^13.0.0",
"bootstrap": "^4.6.0",
"detect-browser": "^5.2.1",
"hls.js": "^1.1.1",

View File

@ -16,7 +16,6 @@ import { MatSliderModule } from "@angular/material/slider";
import { MatTabsModule } from "@angular/material/tabs";
import { MatTooltipModule } from "@angular/material/tooltip";
import { RouterModule } from "@angular/router";
import { AuthModule as OidcModule, LogLevel } from "angular-auth-oidc-client";
import { tap } from "rxjs/operators";
import { AccountComponent } from "./account/account.component";
import { LogoutComponent } from "./logout/logout.component";
@ -48,25 +47,6 @@ import { UnauthorizedComponent } from "./unauthorized/unauthorized.component";
FormsModule,
MatTabsModule,
MatCheckboxModule,
OidcModule.forRoot({
config: {
authority: window.location.origin,
redirectUrl: `${window.location.origin}/`,
postLogoutRedirectUri: `${window.location.origin}/logout`,
clientId: "kyoo.webapp",
responseType: "code",
triggerAuthorizationResultEvent: false,
scope: "openid profile offline_access kyoo.read kyoo.write kyoo.play kyoo.admin",
silentRenew: true,
silentRenewUrl: `${window.location.origin}/silent.html`,
useRefreshToken: true,
startCheckSession: true,
forbiddenRoute: `${window.location.origin}/forbidden`,
unauthorizedRoute: `${window.location.origin}/unauthorized`,
logLevel: LogLevel.Warn
}
}),
RouterModule
],
entryComponents: [

View File

@ -1,7 +1,5 @@
import { Injectable } from "@angular/core";
import { LoginResponse, OidcSecurityService } from "angular-auth-oidc-client";
import { Account } from "../models/account";
import { HttpClient } from "@angular/common/http";
@Injectable({
providedIn: "root"
@ -11,37 +9,15 @@ export class AuthService
isAuthenticated: boolean = false;
account: Account = null;
constructor(private oidcSecurityService: OidcSecurityService, private http: HttpClient)
constructor()
{
this.oidcSecurityService.checkAuth()
.subscribe((auth: LoginResponse) => this.isAuthenticated = auth.isAuthenticated);
this.oidcSecurityService.userData$.subscribe(x =>
{
if (x?.userData == null)
{
this.account = null;
this.isAuthenticated = false;
return;
}
this.account = {
email: x.userData.email,
username: x.userData.username,
picture: x.userData.picture,
permissions: x.userData.permissions?.split(",") ?? []
};
});
}
login(): void
{
this.oidcSecurityService.authorize();
}
logout(): void
{
this.http.get("api/account/logout").subscribe(() =>
{
this.oidcSecurityService.logoff();
});
}
}

View File

@ -1,21 +1,16 @@
import { Injector, Pipe, PipeTransform } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { OidcSecurityService } from "angular-auth-oidc-client";
@Pipe({
name: "auth"
})
export class AuthPipe implements PipeTransform
{
private oidcSecurity: OidcSecurityService;
constructor(private injector: Injector, private http: HttpClient) {}
async transform(uri: string): Promise<string>
{
if (this.oidcSecurity === undefined)
this.oidcSecurity = this.injector.get(OidcSecurityService);
const token: string = this.oidcSecurity.getAccessToken();
const token: string = null;
if (!token)
return uri;
const headers: HttpHeaders = new HttpHeaders({Authorization: "Bearer " + token});

View File

@ -6,23 +6,17 @@ import {
HttpInterceptor
} from "@angular/common/http";
import { Observable } from "rxjs";
import { OidcSecurityService } from "angular-auth-oidc-client";
@Injectable()
export class AuthorizerInterceptor implements HttpInterceptor
{
private oidcSecurity: OidcSecurityService;
constructor(private injector: Injector) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>>
{
if (request.url.startsWith("http"))
return next.handle(request);
if (this.oidcSecurity === undefined)
this.oidcSecurity = this.injector.get(OidcSecurityService);
const token: string = this.oidcSecurity.getAccessToken();
const token: string = null;
if (token)
request = request.clone({setHeaders: {Authorization: "Bearer " + token}});
return next.handle(request);

View File

@ -13,7 +13,6 @@ import {
import { MatSnackBar } from "@angular/material/snack-bar";
import { DomSanitizer, Title } from "@angular/platform-browser";
import { ActivatedRoute, Event, NavigationCancel, NavigationEnd, NavigationStart, Router } from "@angular/router";
import { OidcSecurityService } from "angular-auth-oidc-client";
import Hls from "hls.js";
import { EpisodeService, ShowService } from "../../services/api.service";
import { StartupService } from "../../services/startup.service";
@ -160,7 +159,6 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit
private subtitlesManager: SubtitlesOctopus;
private hlsPlayer: Hls = new Hls();
private oidcSecurity: OidcSecurityService;
constructor(private route: ActivatedRoute,
private snackBar: MatSnackBar,
private title: Title,
@ -242,11 +240,9 @@ export class PlayerComponent implements OnInit, OnDestroy, AfterViewInit
ngAfterViewInit(): void
{
if (this.oidcSecurity === undefined)
this.oidcSecurity = this.injector.get(OidcSecurityService);
this.hlsPlayer.config.xhrSetup = xhr =>
{
const token: string = this.oidcSecurity.getAccessToken();
const token: string = null;
if (token)
xhr.setRequestHeader("Authorization", "Bearer " + token);
};

File diff suppressed because it is too large Load Diff

View File

@ -16,21 +16,16 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Authentication.Models.DTO
using System;
namespace Kyoo.Abstractions.Models.Permissions
{
/// <summary>
/// A model to represent an otac request
/// The annotated route can only be accessed by a logged in user.
/// </summary>
public class OtacRequest
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class UserOnlyAttribute : Attribute
{
/// <summary>
/// The One Time Access Code
/// </summary>
public string Otac { get; set; }
/// <summary>
/// Should the user stay logged
/// </summary>
public bool StayLoggedIn { get; set; }
// TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation.
}
}

View File

@ -17,6 +17,7 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.Collections.Generic;
using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models
{
@ -44,6 +45,7 @@ namespace Kyoo.Abstractions.Models
/// <summary>
/// The user password (hashed, it can't be read like that). The hashing format is implementation defined.
/// </summary>
[SerializeIgnore]
public string Password { get; set; }
/// <summary>
@ -54,6 +56,7 @@ namespace Kyoo.Abstractions.Models
/// <summary>
/// Arbitrary extra data that can be used by specific authentication implementations.
/// </summary>
[SerializeIgnore]
public Dictionary<string, string> ExtraData { get; set; }
/// <inheritdoc />
@ -62,11 +65,13 @@ namespace Kyoo.Abstractions.Models
/// <summary>
/// The list of shows the user has finished.
/// </summary>
[SerializeIgnore]
public ICollection<Show> Watched { get; set; }
/// <summary>
/// The list of episodes the user is watching (stopped in progress or the next episode of the show)
/// </summary>
[SerializeIgnore]
public ICollection<WatchedEpisode> CurrentlyWatching { get; set; }
}
}

View File

@ -31,25 +31,30 @@ namespace Kyoo.Abstractions.Models.Utils
/// </summary>
public const int AlternativeRoute = 1;
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users.
/// </summary>
public const string UsersGroup = "0:Users";
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo.
/// </summary>
public const string ResourcesGroup = "0:Resources";
public const string ResourcesGroup = "1:Resources";
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>.
/// It should be used for sub resources of kyoo that help define the main resources.
/// </summary>
public const string MetadataGroup = "1:Metadata";
public const string MetadataGroup = "2:Metadata";
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback.
/// </summary>
public const string WatchGroup = "2:Watch";
public const string WatchGroup = "3:Watch";
/// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
/// </summary>
public const string AdminGroup = "3:Admin";
public const string AdminGroup = "4:Admin";
}
}

View File

@ -18,28 +18,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using Autofac;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Kyoo.Abstractions;
using Kyoo.Abstractions.Controllers;
using Kyoo.Authentication.Models;
using Kyoo.Authentication.Views;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Logging;
using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode;
using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication
{
@ -55,14 +43,13 @@ namespace Kyoo.Authentication
public string Name => "Authentication";
/// <inheritdoc />
public string Description => "Enable OpenID authentication for Kyoo.";
public string Description => "Enable an authentication/permission system for Kyoo (via Jwt or ApiKeys).";
/// <inheritdoc />
public Dictionary<string, Type> Configuration => new()
{
{ AuthenticationOption.Path, typeof(AuthenticationOption) },
{ PermissionOption.Path, typeof(PermissionOption) },
{ CertificateOption.Path, typeof(CertificateOption) }
};
/// <summary>
@ -71,130 +58,50 @@ namespace Kyoo.Authentication
private readonly IConfiguration _configuration;
/// <summary>
/// The logger used to allow IdentityServer to log things.
/// </summary>
private readonly ILogger<DefaultCorsPolicyService> _logger;
/// <summary>
/// The environment information to check if the app runs in debug mode
/// </summary>
private readonly IWebHostEnvironment _environment;
/// <summary>
/// Create a new authentication module instance and use the given configuration and environment.
/// Create a new authentication module instance and use the given configuration.
/// </summary>
/// <param name="configuration">The configuration to use</param>
/// <param name="logger">The logger used to allow IdentityServer to log things</param>
/// <param name="environment">The environment information to check if the app runs in debug mode</param>
[SuppressMessage("ReSharper", "ContextualLoggerProblem",
Justification = "The logger is used for a dependency that is not created via the container.")]
public AuthenticationModule(IConfiguration configuration,
ILogger<DefaultCorsPolicyService> logger,
IWebHostEnvironment environment)
public AuthenticationModule(IConfiguration configuration)
{
_configuration = configuration;
_logger = logger;
_environment = environment;
}
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance();
DefaultCorsPolicyService cors = new(_logger)
{
AllowedOrigins = { _configuration.GetPublicUrl().GetLeftPart(UriPartial.Authority) }
};
builder.RegisterInstance(cors).As<ICorsPolicyService>().SingleInstance();
builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance();
}
/// <inheritdoc />
public void Configure(IServiceCollection services)
{
Uri publicUrl = _configuration.GetPublicUrl();
if (_environment.IsDevelopment())
IdentityModelEventSource.ShowPII = true;
services.AddControllers();
AuthenticationOption jwt = ConfigurationBinder.Get<AuthenticationOption>(
_configuration.GetSection(AuthenticationOption.Path)
);
// TODO handle direct-videos with bearers (probably add a cookie and a app.Use to translate that for videos)
// TODO Check if tokens should be stored.
List<Client> clients = new();
_configuration.GetSection("authentication:clients").Bind(clients);
CertificateOption certificateOptions = new();
_configuration.GetSection(CertificateOption.Path).Bind(certificateOptions);
clients.AddRange(IdentityContext.GetClients());
foreach (Client client in clients)
{
client.RedirectUris = client.RedirectUris
.Select(x => x.StartsWith("/") ? publicUrl.ToString().TrimEnd('/') + x : x)
.ToArray();
}
services.AddIdentityServer(options =>
{
options.IssuerUri = publicUrl.ToString();
options.UserInteraction.LoginUrl = $"{publicUrl}login";
options.UserInteraction.ErrorUrl = $"{publicUrl}error";
options.UserInteraction.LogoutUrl = $"{publicUrl}logout";
})
.AddInMemoryIdentityResources(IdentityContext.GetIdentityResources())
.AddInMemoryApiScopes(IdentityContext.GetScopes())
.AddInMemoryApiResources(IdentityContext.GetApis())
.AddInMemoryClients(clients)
.AddProfileService<AccountApi>()
.AddSigninKeys(certificateOptions);
services.AddAuthentication()
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = publicUrl.ToString();
options.Audience = "kyoo";
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = publicUrl.ToString(),
ValidAudience = publicUrl.ToString(),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret))
};
});
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps => new IStartupAction[]
{
SA.New<IApplicationBuilder>(app =>
{
PhysicalFileProvider provider = new(Path.Combine(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!,
"login"));
app.UseDefaultFiles(new DefaultFilesOptions
{
RequestPath = new PathString("/login"),
FileProvider = provider,
RedirectToAppendTrailingSlash = true
});
app.UseStaticFiles(new StaticFileOptions
{
RequestPath = new PathString("/login"),
FileProvider = provider
});
}, SA.StaticFiles),
SA.New<IApplicationBuilder>(app =>
{
app.UseCookiePolicy(new CookiePolicyOptions
{
MinimumSameSitePolicy = SameSiteMode.Strict
});
app.UseAuthentication();
}, SA.Authentication),
SA.New<IApplicationBuilder>(app =>
{
app.Use((ctx, next) =>
{
ctx.SetIdentityServerOrigin(_configuration.GetPublicUrl().ToString());
return next();
});
app.UseIdentityServer();
}, SA.Endpoint),
SA.New<IApplicationBuilder>(app => app.UseAuthorization(), SA.Authorization)
SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication),
};
}
}

View File

@ -1,137 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using Kyoo.Authentication.Models;
using Microsoft.Extensions.DependencyInjection;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Operators;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.Pkcs;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Utilities;
using Org.BouncyCastle.X509;
using X509Certificate = Org.BouncyCastle.X509.X509Certificate;
namespace Kyoo.Authentication
{
/// <summary>
/// A class containing multiple extensions methods to manage certificates.
/// </summary>
public static class Certificates
{
/// <summary>
/// Add the certificate file to the identity server. If the certificate will expire soon, automatically renew it.
/// If no certificate exists, one is generated.
/// </summary>
/// <param name="builder">The identity server that will be modified.</param>
/// <param name="options">The certificate options</param>
/// <returns>The initial builder to allow chain-calls.</returns>
public static IIdentityServerBuilder AddSigninKeys(this IIdentityServerBuilder builder,
CertificateOption options)
{
X509Certificate2 certificate = _GetCertificate(options);
builder.AddSigningCredential(certificate);
if (certificate.NotAfter.AddDays(-7) <= DateTime.UtcNow)
{
Console.WriteLine("Signin certificate will expire soon, renewing it.");
if (File.Exists(options.OldFile))
File.Delete(options.OldFile);
File.Move(options.File, options.OldFile);
builder.AddValidationKey(_GenerateCertificate(options.File, options.Password));
}
else if (File.Exists(options.OldFile))
builder.AddValidationKey(_GetExistingCredential(options.OldFile, options.Password));
return builder;
}
/// <summary>
/// Get or generate the sign-in certificate.
/// </summary>
/// <param name="options">The certificate options</param>
/// <returns>A valid certificate</returns>
private static X509Certificate2 _GetCertificate(CertificateOption options)
{
return File.Exists(options.File)
? _GetExistingCredential(options.File, options.Password)
: _GenerateCertificate(options.File, options.Password);
}
/// <summary>
/// Load a certificate from a file
/// </summary>
/// <param name="file">The path of the certificate</param>
/// <param name="password">The password of the certificate</param>
/// <returns>The loaded certificate</returns>
private static X509Certificate2 _GetExistingCredential(string file, string password)
{
X509KeyStorageFlags storeFlags = X509KeyStorageFlags.MachineKeySet |
X509KeyStorageFlags.PersistKeySet |
X509KeyStorageFlags.Exportable;
return new X509Certificate2(file, password, storeFlags);
}
/// <summary>
/// Generate a new certificate key and put it in the file at <paramref name="file"/>.
/// </summary>
/// <param name="file">The path of the output file</param>
/// <param name="password">The password of the new certificate</param>
/// <returns>The generated certificate</returns>
private static X509Certificate2 _GenerateCertificate(string file, string password)
{
SecureRandom random = new();
X509V3CertificateGenerator certificateGenerator = new();
certificateGenerator.SetSerialNumber(BigIntegers.CreateRandomInRange(BigInteger.One,
BigInteger.ValueOf(long.MaxValue), random));
certificateGenerator.SetIssuerDN(new X509Name($"C=NL, O=SDG, CN=Kyoo"));
certificateGenerator.SetSubjectDN(new X509Name($"C=NL, O=SDG, CN=Kyoo"));
certificateGenerator.SetNotBefore(DateTime.UtcNow.Date);
certificateGenerator.SetNotAfter(DateTime.UtcNow.Date.AddMonths(3));
KeyGenerationParameters keyGenerationParameters = new(random, 2048);
RsaKeyPairGenerator keyPairGenerator = new();
keyPairGenerator.Init(keyGenerationParameters);
AsymmetricCipherKeyPair subjectKeyPair = keyPairGenerator.GenerateKeyPair();
certificateGenerator.SetPublicKey(subjectKeyPair.Public);
const string signatureAlgorithm = "MD5WithRSA";
Asn1SignatureFactory signatureFactory = new(signatureAlgorithm, subjectKeyPair.Private);
X509Certificate bouncyCert = certificateGenerator.Generate(signatureFactory);
Pkcs12Store store = new Pkcs12StoreBuilder().Build();
store.SetKeyEntry("Kyoo_key", new AsymmetricKeyEntry(subjectKeyPair.Private), new[]
{
new X509CertificateEntry(bouncyCert)
});
using MemoryStream pfxStream = new();
store.Save(pfxStream, password.ToCharArray(), random);
X509Certificate2 certificate = new(pfxStream.ToArray(), password, X509KeyStorageFlags.Exportable);
using FileStream fileStream = File.OpenWrite(file);
pfxStream.WriteTo(fileStream);
return certificate;
}
}
}

View File

@ -0,0 +1,54 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Threading.Tasks;
using Kyoo.Abstractions.Models;
using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication
{
/// <summary>
/// The service that controls jwt creation and validation.
/// </summary>
public interface ITokenController
{
/// <summary>
/// Create a new access token for the given user.
/// </summary>
/// <param name="user">The user to create a token for.</param>
/// <param name="expireIn">When this token will expire.</param>
/// <returns>A new, valid access token.</returns>
string CreateAccessToken(User user, out TimeSpan expireIn);
/// <summary>
/// Create a new refresh token for the given user.
/// </summary>
/// <param name="user">The user to create a token for.</param>
/// <returns>A new, valid refresh token.</returns>
Task<string> CreateRefreshToken(User user);
/// <summary>
/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to.
/// </summary>
/// <param name="refreshToken">The refresh token to validate.</param>
/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception>
/// <returns>The id of the token's user.</returns>
int GetRefreshTokenUserID(string refreshToken);
}
}

View File

@ -1,77 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Linq;
using System.Security.Cryptography;
using IdentityModel;
namespace Kyoo.Authentication
{
/// <summary>
/// Some functions to handle password management.
/// </summary>
public static class PasswordUtils
{
/// <summary>
/// Generate an OneTimeAccessCode.
/// </summary>
/// <returns>A new otac.</returns>
public static string GenerateOTAC()
{
return CryptoRandom.CreateUniqueId();
}
/// <summary>
/// Hash a password to store it has a verification only.
/// </summary>
/// <param name="password">The password to hash</param>
/// <returns>The hashed password</returns>
public static string HashPassword(string password)
{
byte[] salt = new byte[16];
#pragma warning disable SYSLIB0023
new RNGCryptoServiceProvider().GetBytes(salt);
#pragma warning restore SYSLIB0023
Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000);
byte[] hash = pbkdf2.GetBytes(20);
byte[] hashBytes = new byte[36];
Array.Copy(salt, 0, hashBytes, 0, 16);
Array.Copy(hash, 0, hashBytes, 16, 20);
return Convert.ToBase64String(hashBytes);
}
/// <summary>
/// Check if a password is the same as a valid hashed password.
/// </summary>
/// <param name="password">The password to check</param>
/// <param name="validPassword">
/// The valid hashed password. This password must be hashed via <see cref="HashPassword"/>.
/// </param>
/// <returns>True if the password is valid, false otherwise.</returns>
public static bool CheckPassword(string password, string validPassword)
{
byte[] validHash = Convert.FromBase64String(validPassword);
byte[] salt = new byte[16];
Array.Copy(validHash, 0, salt, 0, 16);
Rfc2898DeriveBytes pbkdf2 = new(password, salt, 100000);
byte[] hash = pbkdf2.GetBytes(20);
return hash.SequenceEqual(validHash.Skip(16));
}
}
}

View File

@ -72,7 +72,7 @@ namespace Kyoo.Authentication
/// <summary>
/// The permission to validate.
/// </summary>
private readonly string _permission;
private readonly string? _permission;
/// <summary>
/// The kind of permission needed.
@ -96,7 +96,10 @@ namespace Kyoo.Authentication
/// <param name="kind">The kind of permission needed.</param>
/// <param name="group">The group of the permission.</param>
/// <param name="options">The option containing default values.</param>
public PermissionValidatorFilter(string permission, Kind kind, Group group,
public PermissionValidatorFilter(
string permission,
Kind kind,
Group group,
IOptionsMonitor<PermissionOption> options)
{
_permission = permission;
@ -133,7 +136,7 @@ namespace Kyoo.Authentication
/// <inheritdoc />
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
string permission = _permission;
string? permission = _permission;
Kind? kind = _kind;
if (permission == null || kind == null)

View File

@ -0,0 +1,144 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Kyoo.Abstractions;
using Kyoo.Abstractions.Models;
using Kyoo.Authentication.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication
{
/// <summary>
/// The service that controls jwt creation and validation.
/// </summary>
public class TokenController : ITokenController
{
/// <summary>
/// The options that this controller will use.
/// </summary>
private readonly IOptions<AuthenticationOption> _options;
/// <summary>
/// The configuration used to retrieve the public URL of kyoo.
/// </summary>
private readonly IConfiguration _configuration;
/// <summary>
/// Create a new <see cref="TokenController"/>.
/// </summary>
/// <param name="options">The options that this controller will use.</param>
/// <param name="configuration">The configuration used to retrieve the public URL of kyoo.</param>
public TokenController(IOptions<AuthenticationOption> options, IConfiguration configuration)
{
_options = options;
_configuration = configuration;
}
/// <inheritdoc />
public string CreateAccessToken(User user, out TimeSpan expireIn)
{
expireIn = new TimeSpan(1, 0, 0);
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
string permissions = user.Permissions != null
? string.Join(',', user.Permissions)
: string.Empty;
List<Claim> claims = new()
{
new Claim(ClaimTypes.NameIdentifier, user.ID.ToString(CultureInfo.InvariantCulture)),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, permissions),
new Claim("type", "access")
};
if (user.Email != null)
claims.Add(new Claim(ClaimTypes.Email, user.Email));
JwtSecurityToken token = new(
signingCredentials: credential,
issuer: _configuration.GetPublicUrl().ToString(),
audience: _configuration.GetPublicUrl().ToString(),
claims: claims,
expires: DateTime.UtcNow.Add(expireIn)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <inheritdoc />
public Task<string> CreateRefreshToken(User user)
{
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
JwtSecurityToken token = new(
signingCredentials: credential,
issuer: _configuration.GetPublicUrl().ToString(),
audience: _configuration.GetPublicUrl().ToString(),
claims: new[]
{
new Claim(ClaimTypes.NameIdentifier, user.ID.ToString(CultureInfo.InvariantCulture)),
new Claim("guid", Guid.NewGuid().ToString()),
new Claim("type", "refresh")
},
expires: DateTime.UtcNow.AddYears(1)
);
// TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user.
return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token));
}
/// <inheritdoc />
public int GetRefreshTokenUserID(string refreshToken)
{
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Value.Secret));
JwtSecurityTokenHandler tokenHandler = new();
ClaimsPrincipal principal;
try
{
principal = tokenHandler.ValidateToken(refreshToken, new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = _configuration.GetPublicUrl().ToString(),
ValidAudience = _configuration.GetPublicUrl().ToString(),
IssuerSigningKey = key
}, out SecurityToken _);
}
catch (Exception ex)
{
throw new SecurityTokenException(ex.Message);
}
if (principal.Claims.First(x => x.Type == "type").Value != "refresh")
throw new SecurityTokenException("Invalid token type. The token should be a refresh token.");
Claim identifier = principal.Claims.First(x => x.Type == ClaimTypes.NameIdentifier);
if (int.TryParse(identifier.Value, out int id))
return id;
throw new SecurityTokenException("Token not associated to any user.");
}
}
}

View File

@ -16,12 +16,10 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using IdentityModel;
using IdentityServer4;
using Kyoo.Abstractions.Models;
namespace Kyoo.Authentication
{
@ -30,35 +28,6 @@ namespace Kyoo.Authentication
/// </summary>
public static class Extensions
{
/// <summary>
/// Get claims of an user.
/// </summary>
/// <param name="user">The user concerned</param>
/// <returns>The list of claims the user has</returns>
public static ICollection<Claim> GetClaims(this User user)
{
return new[]
{
new Claim(JwtClaimTypes.Subject, user.ID.ToString()),
new Claim(JwtClaimTypes.Name, user.Username),
new Claim(JwtClaimTypes.Picture, $"api/account/picture/{user.Slug}")
};
}
/// <summary>
/// Convert a user to an IdentityServerUser.
/// </summary>
/// <param name="user">The user to convert.</param>
/// <returns>The corresponding identity server user.</returns>
public static IdentityServerUser ToIdentityUser(this User user)
{
return new IdentityServerUser(user.ID.ToString())
{
DisplayName = user.Username,
AdditionalClaims = new[] { new Claim("permissions", string.Join(',', user.Permissions)) }
};
}
/// <summary>
/// Get the permissions of an user.
/// </summary>
@ -66,7 +35,8 @@ namespace Kyoo.Authentication
/// <returns>The list of permissions</returns>
public static ICollection<string> GetPermissions(this ClaimsPrincipal user)
{
return user.Claims.FirstOrDefault(x => x.Type == "permissions")?.Value.Split(',');
return user.Claims.FirstOrDefault(x => x.Type == "permissions")?.Value.Split(',')
?? Array.Empty<string>();
}
}
}

View File

@ -1,24 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<LoginRoot>../Kyoo.WebLogin/</LoginRoot>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.Storage" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
<PackageReference Include="Portable.BouncyCastle" Version="1.8.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.12" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="$(LoginRoot)**;" />
<Content Include="$(LoginRoot)**" Visible="false">
<Link>login/%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -1,46 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
namespace Kyoo.Authentication.Models.DTO
{
/// <summary>
/// A model only used on account update requests.
/// </summary>
public class AccountUpdateRequest
{
/// <summary>
/// The new email address of the user
/// </summary>
[EmailAddress(ErrorMessage = "The email is invalid.")]
public string Email { get; set; }
/// <summary>
/// The new username of the user.
/// </summary>
[MinLength(4, ErrorMessage = "The username must have at least 4 characters")]
public string Username { get; set; }
/// <summary>
/// The picture icon.
/// </summary>
public IFormFile Picture { get; set; }
}
}

View File

@ -34,13 +34,14 @@ namespace Kyoo.Authentication.Models.DTO
public string Password { get; set; }
/// <summary>
/// Should the user stay logged in? If true a cookie will be put.
/// Initializes a new instance of the <see cref="LoginRequest"/> class.
/// </summary>
public bool StayLoggedIn { get; set; }
/// <summary>
/// The return url of the login flow.
/// </summary>
public string ReturnURL { get; set; }
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public LoginRequest(string username, string password)
{
Username = username;
Password = password;
}
}
}

View File

@ -1,41 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Authentication.Models.DTO
{
/// <summary>
/// A one time access token
/// </summary>
public class OtacResponse
{
/// <summary>
/// The One Time Access Token that allow one to connect to an account without typing a password or without
/// any kind of verification. This is valid only one time and only for a short period of time.
/// </summary>
public string OTAC { get; set; }
/// <summary>
/// Create a new <see cref="OtacResponse"/>.
/// </summary>
/// <param name="otac">The one time access token.</param>
public OtacResponse(string otac)
{
OTAC = otac;
}
}
}

View File

@ -20,6 +20,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Kyoo.Abstractions.Models;
using Kyoo.Utils;
using BCryptNet = BCrypt.Net.BCrypt;
namespace Kyoo.Authentication.Models.DTO
{
@ -43,9 +44,22 @@ namespace Kyoo.Authentication.Models.DTO
/// <summary>
/// The user's password.
/// </summary>
[MinLength(8, ErrorMessage = "The password must have at least {1} characters")]
[MinLength(4, ErrorMessage = "The password must have at least {1} characters")]
public string Password { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="RegisterRequest"/> class.
/// </summary>
/// <param name="email">The user email address.</param>
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public RegisterRequest(string email, string username, string password)
{
Email = email;
Username = username;
Password = password;
}
/// <summary>
/// Convert this register request to a new <see cref="User"/> class.
/// </summary>
@ -56,7 +70,7 @@ namespace Kyoo.Authentication.Models.DTO
{
Slug = Utility.ToSlug(Username),
Username = Username,
Password = Password,
Password = BCryptNet.HashPassword(Password),
Email = Email,
ExtraData = new Dictionary<string, string>()
};

View File

@ -1,121 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.Collections.Generic;
using System.Linq;
using IdentityServer4.Models;
namespace Kyoo.Authentication
{
/// <summary>
/// The hard coded context of the identity server.
/// </summary>
public static class IdentityContext
{
/// <summary>
/// The list of identity resources supported (email, profile and openid)
/// </summary>
/// <returns>The list of identity resources supported</returns>
public static IEnumerable<IdentityResource> GetIdentityResources()
{
return new List<IdentityResource>
{
new IdentityResources.OpenId(),
new IdentityResources.Email(),
new IdentityResources.Profile()
};
}
/// <summary>
/// Get the list of officially supported clients.
/// </summary>
/// <remarks>
/// You can add custom clients in the settings.json file.
/// </remarks>
/// <returns>The list of officially supported clients.</returns>
public static IEnumerable<Client> GetClients()
{
return new List<Client>
{
new()
{
ClientId = "kyoo.webapp",
AccessTokenType = AccessTokenType.Jwt,
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
AllowAccessTokensViaBrowser = true,
AllowOfflineAccess = true,
RequireConsent = false,
AllowedScopes = { "openid", "profile", "kyoo.read", "kyoo.write", "kyoo.play", "kyoo.admin" },
RedirectUris = { "/", "/silent.html" },
PostLogoutRedirectUris = { "/logout" }
}
};
}
/// <summary>
/// The list of scopes supported by the API.
/// </summary>
/// <returns>The list of scopes</returns>
public static IEnumerable<ApiScope> GetScopes()
{
return new[]
{
new ApiScope
{
Name = "kyoo.read",
DisplayName = "Read only access to the API.",
},
new ApiScope
{
Name = "kyoo.write",
DisplayName = "Read and write access to the public API"
},
new ApiScope
{
Name = "kyoo.play",
DisplayName = "Allow playback of movies and episodes."
},
new ApiScope
{
Name = "kyoo.admin",
DisplayName = "Full access to the admin's API and the public API."
}
};
}
/// <summary>
/// The list of APIs (this is used to create Audiences)
/// </summary>
/// <returns>The list of apis</returns>
public static IEnumerable<ApiResource> GetApis()
{
return new[]
{
new ApiResource("kyoo", "Kyoo")
{
Scopes = GetScopes().Select(x => x.Name).ToArray()
}
};
}
}
}

View File

@ -0,0 +1,72 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace Kyoo.Authentication
{
/// <summary>
/// A container representing the response of a login or token refresh.
/// </summary>
public class JwtToken
{
/// <summary>
/// The type of this token (always a Bearer).
/// </summary>
[JsonProperty("token_token")]
[JsonPropertyName("token_type")]
public string TokenType => "Bearer";
/// <summary>
/// The access token used to authorize requests.
/// </summary>
[JsonProperty("access_token")]
[JsonPropertyName("access_token")]
public string AccessToken { get; set; }
/// <summary>
/// The refresh token used to retrieve a new access/refresh token when the access token has expired.
/// </summary>
[JsonProperty("refresh_token")]
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }
/// <summary>
/// When the access token will expire. After this time, the refresh token should be used to retrieve.
/// a new token.cs
/// </summary>
[JsonProperty("expire_in")]
[JsonPropertyName("expire_in")]
public TimeSpan ExpireIn { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="JwtToken"/> class.
/// </summary>
/// <param name="accessToken">The access token used to authorize requests.</param>
/// <param name="refreshToken">The refresh token to retrieve a new access token.</param>
/// <param name="expireIn">When the access token will expire.</param>
public JwtToken(string accessToken, string refreshToken, TimeSpan expireIn)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpireIn = expireIn;
}
}
}

View File

@ -29,18 +29,23 @@ namespace Kyoo.Authentication.Models
public const string Path = "authentication";
/// <summary>
/// The options for certificates
/// The default jwt secret.
/// </summary>
public CertificateOption Certificate { get; set; }
public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?";
/// <summary>
/// The secret used to encrypt the jwt.
/// </summary>
public string Secret { get; set; } = DefaultSecret;
/// <summary>
/// Options for permissions
/// </summary>
public PermissionOption Permissions { get; set; }
public PermissionOption Permissions { get; set; } = new();
/// <summary>
/// Root path of user's profile pictures.
/// </summary>
public string ProfilePicturePath { get; set; }
public string ProfilePicturePath { get; set; } = "users/";
}
}

View File

@ -1,46 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Authentication.Models
{
/// <summary>
/// A typed option model for the certificate
/// </summary>
public class CertificateOption
{
/// <summary>
/// The path to get this option from the root configuration.
/// </summary>
public const string Path = "authentication:certificate";
/// <summary>
/// The path of the certificate file.
/// </summary>
public string File { get; set; }
/// <summary>
/// The path of the old certificate file.
/// </summary>
public string OldFile { get; set; }
/// <summary>
/// The password of the certificates.
/// </summary>
public string Password { get; set; }
}
}

View File

@ -31,11 +31,11 @@ namespace Kyoo.Authentication.Models
/// <summary>
/// The default permissions that will be given to a non-connected user.
/// </summary>
public string[] Default { get; set; }
public string[] Default { get; set; } = new[] { "overall.read", "overall.write" };
/// <summary>
/// Permissions applied to a new user.
/// </summary>
public string[] NewUser { get; set; }
public string[] NewUser { get; set; } = new[] { "overall.read", "overall.write" };
}
}

View File

@ -1,263 +0,0 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Authentication.Models;
using Kyoo.Authentication.Models.DTO;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Authentication.Views
{
/// <summary>
/// The endpoint responsible for login, logout, permissions and claims of a user.
/// Documentation of this endpoint is a work in progress.
/// </summary>
/// TODO document this well.
[Route("api/accounts")]
[Route("api/account", Order = AlternativeRoute)]
[ApiController]
[ApiDefinition("Account")]
public class AccountApi : Controller, IProfileService
{
/// <summary>
/// The repository to handle users.
/// </summary>
private readonly IUserRepository _users;
/// <summary>
/// A file manager to send profile pictures
/// </summary>
private readonly IFileSystem _files;
/// <summary>
/// Options about authentication. Those options are monitored and reloads are supported.
/// </summary>
private readonly IOptions<AuthenticationOption> _options;
/// <summary>
/// Create a new <see cref="AccountApi"/> handle to handle login/users requests.
/// </summary>
/// <param name="users">The user repository to create and manage users</param>
/// <param name="files">A file manager to send profile pictures</param>
/// <param name="options">Authentication options (this may be hot reloaded)</param>
public AccountApi(IUserRepository users,
IFileSystem files,
IOptions<AuthenticationOption> options)
{
_users = users;
_files = files;
_options = options;
}
/// <summary>
/// Register
/// </summary>
/// <remarks>
/// Register a new user and return a OTAC to connect to it.
/// </remarks>
/// <param name="request">The DTO register request</param>
/// <returns>A OTAC to connect to this new account</returns>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
public async Task<ActionResult<OtacResponse>> Register([FromBody] RegisterRequest request)
{
User user = request.ToUser();
user.Permissions = _options.Value.Permissions.NewUser;
user.Password = PasswordUtils.HashPassword(user.Password);
user.ExtraData["otac"] = PasswordUtils.GenerateOTAC();
user.ExtraData["otac-expire"] = DateTime.Now.AddMinutes(1).ToString("s");
try
{
await _users.Create(user);
}
catch (DuplicatedItemException)
{
return Conflict(new RequestError("A user with this name already exists"));
}
return Ok(new OtacResponse(user.ExtraData["otac"]));
}
/// <summary>
/// Return an authentication properties based on a stay login property
/// </summary>
/// <param name="stayLogged">Should the user stay logged</param>
/// <returns>Authentication properties based on a stay login</returns>
private static AuthenticationProperties _StayLogged(bool stayLogged)
{
if (!stayLogged)
return null;
return new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(1)
};
}
/// <summary>
/// Login
/// </summary>
/// <remarks>
/// Login the current session.
/// </remarks>
/// <param name="login">The DTO login request</param>
/// <returns>TODO</returns>
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest login)
{
User user = await _users.GetOrDefault(x => x.Username == login.Username);
if (user == null)
return Unauthorized();
if (!PasswordUtils.CheckPassword(login.Password, user.Password))
return Unauthorized();
await HttpContext.SignInAsync(user.ToIdentityUser(), _StayLogged(login.StayLoggedIn));
return Ok(new { RedirectUrl = login.ReturnURL, IsOk = true });
}
/// <summary>
/// Use a OTAC to login a user.
/// </summary>
/// <param name="otac">The OTAC request</param>
/// <returns>TODO</returns>
[HttpPost("otac-login")]
public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac)
{
// TODO once hstore (Dictionary<string, string> accessor) are supported, use them.
// We retrieve all users, this is inefficient.
User user = (await _users.GetAll()).FirstOrDefault(x => x.ExtraData.GetValueOrDefault("otac") == otac.Otac);
if (user == null)
return Unauthorized();
if (DateTime.ParseExact(user.ExtraData["otac-expire"], "s", CultureInfo.InvariantCulture) <=
DateTime.UtcNow)
{
return BadRequest(new
{
code = "ExpiredOTAC",
description = "The OTAC has expired. Try to login with your password."
});
}
await HttpContext.SignInAsync(user.ToIdentityUser(), _StayLogged(otac.StayLoggedIn));
return Ok();
}
/// <summary>
/// Sign out an user
/// </summary>
/// <returns>TODO</returns>
[HttpGet("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync();
return Ok();
}
/// <inheritdoc />
[ApiExplorerSettings(IgnoreApi = true)]
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
User user = await _users.GetOrDefault(int.Parse(context.Subject.GetSubjectId()));
if (user == null)
return;
context.IssuedClaims.AddRange(user.GetClaims());
context.IssuedClaims.Add(new Claim("permissions", string.Join(',', user.Permissions)));
}
/// <inheritdoc />
[ApiExplorerSettings(IgnoreApi = true)]
public async Task IsActiveAsync(IsActiveContext context)
{
User user = await _users.GetOrDefault(int.Parse(context.Subject.GetSubjectId()));
context.IsActive = user != null;
}
/// <summary>
/// Get the user's profile picture.
/// </summary>
/// <param name="slug">The user slug</param>
/// <returns>The profile picture of the user or 404 if not found</returns>
[HttpGet("picture/{slug}")]
public async Task<IActionResult> GetPicture(string slug)
{
User user = await _users.GetOrDefault(slug);
if (user == null)
return NotFound();
string path = Path.Combine(_options.Value.ProfilePicturePath, user.ID.ToString());
return _files.FileResult(path);
}
/// <summary>
/// Update profile information (email, username, profile picture...)
/// </summary>
/// <param name="data">The new information</param>
/// <returns>The edited user</returns>
[HttpPut]
[Authorize]
public async Task<ActionResult<User>> Update([FromForm] AccountUpdateRequest data)
{
User user = await _users.GetOrDefault(int.Parse(HttpContext.User.GetSubjectId()));
if (user == null)
return Unauthorized();
if (!string.IsNullOrEmpty(data.Email))
user.Email = data.Email;
if (!string.IsNullOrEmpty(data.Username))
user.Username = data.Username;
if (data.Picture?.Length > 0)
{
string path = _files.Combine(_options.Value.ProfilePicturePath, user.ID.ToString());
await using Stream file = await _files.NewFile(path);
await data.Picture.CopyToAsync(file);
}
return await _users.Edit(user, false);
}
/// <summary>
/// Get permissions for a non connected user.
/// </summary>
/// <returns>The list of permissions of a default user.</returns>
[HttpGet("permissions")]
public ActionResult<IEnumerable<string>> GetDefaultPermissions()
{
return _options.Value.Permissions.Default ?? Array.Empty<string>();
}
}
}

View File

@ -0,0 +1,254 @@
// Kyoo - A portable and vast media library solution.
// Copyright (c) Kyoo.
//
// See AUTHORS.md and LICENSE file in the project root for full license information.
//
// Kyoo is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// any later version.
//
// Kyoo is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Permissions;
using Kyoo.Abstractions.Models.Utils;
using Kyoo.Authentication.Models.DTO;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using static Kyoo.Abstractions.Models.Utils.Constants;
using BCryptNet = BCrypt.Net.BCrypt;
namespace Kyoo.Authentication.Views
{
/// <summary>
/// Sign in, Sign up or refresh tokens.
/// </summary>
[ApiController]
[Route("api/auth")]
[ApiDefinition("Authentication", Group = UsersGroup)]
public class AuthApi : ControllerBase
{
/// <summary>
/// The repository to handle users.
/// </summary>
private readonly IUserRepository _users;
/// <summary>
/// The token generator.
/// </summary>
private readonly ITokenController _token;
/// <summary>
/// Create a new <see cref="AuthApi"/>.
/// </summary>
/// <param name="users">The repository used to check if the user exists.</param>
/// <param name="token">The token generator.</param>
public AuthApi(IUserRepository users, ITokenController token)
{
_users = users;
_token = token;
}
/// <summary>
/// Create a new Forbidden result from an object.
/// </summary>
/// <param name="value">The json value to output on the response.</param>
/// <returns>A new forbidden result with the given json object.</returns>
public static ObjectResult Forbid(object value)
{
return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
}
/// <summary>
/// Login.
/// </summary>
/// <remarks>
/// Login as a user and retrieve an access and a refresh token.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="403">The user and password does not match.</response>
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
{
User user = await _users.GetOrDefault(x => x.Username == request.Username);
if (user == null || !BCryptNet.Verify(request.Password, user.Password))
return Forbid(new RequestError("The user and password does not match."));
return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user),
expireIn
);
}
/// <summary>
/// Register.
/// </summary>
/// <remarks>
/// Register a new user and get a new access/refresh token for this new user.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="400">The request is invalid.</response>
/// <response code="409">A user already exists with this username or email address.</response>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{
User user = request.ToUser();
try
{
await _users.Create(user);
}
catch (DuplicatedItemException)
{
return Conflict(new RequestError("A user already exists with this username."));
}
return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user),
expireIn
);
}
/// <summary>
/// Refresh a token.
/// </summary>
/// <remarks>
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
/// </remarks>
/// <param name="token">A valid refresh token.</param>
/// <returns>A new access and refresh token.</returns>
/// <response code="403">The given refresh token is invalid.</response>
[HttpGet("refresh")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
{
try
{
int userId = _token.GetRefreshTokenUserID(token);
User user = await _users.Get(userId);
return new JwtToken(
_token.CreateAccessToken(user, out TimeSpan expireIn),
await _token.CreateRefreshToken(user),
expireIn
);
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid refresh token."));
}
catch (SecurityTokenException ex)
{
return Forbid(new RequestError(ex.Message));
}
}
/// <summary>
/// Get authenticated user.
/// </summary>
/// <remarks>
/// Get information about the currently authenticated user. This can also be used to ensure that you are
/// logged in.
/// </remarks>
/// <returns>The currently authenticated user.</returns>
/// <response code="403">The given access token is invalid.</response>
[HttpGet("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<User>> GetMe()
{
if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID))
return Forbid();
try
{
return await _users.Get(userID);
}
catch (ItemNotFoundException)
{
return Forbid();
}
}
/// <summary>
/// Edit self
/// </summary>
/// <remarks>
/// Edit information about the currently authenticated user.
/// </remarks>
/// <param name="user">The new data for the current user.</param>
/// <param name="resetOld">
/// Should old properties of the resource be discarded or should null values considered as not changed?
/// </param>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="403">The given access token is invalid.</response>
[HttpPut("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<User>> EditMe(User user, [FromQuery] bool resetOld = true)
{
if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID))
return Forbid();
try
{
user.ID = userID;
return await _users.Edit(user, resetOld);
}
catch (ItemNotFoundException)
{
return Forbid();
}
}
/// <summary>
/// Delete account
/// </summary>
/// <remarks>
/// Delete the current account.
/// </remarks>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="403">The given access token is invalid.</response>
[HttpDelete("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<User>> DeleteMe()
{
if (!int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out int userID))
return Forbid();
try
{
await _users.Delete(userID);
return NoContent();
}
catch (ItemNotFoundException)
{
return Forbid();
}
}
}
}

View File

@ -118,6 +118,7 @@ namespace Kyoo.Core
/// <inheritdoc />
public void Configure(IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddTransient<IConfigureOptions<MvcNewtonsoftJsonOptions>, JsonOptions>();
services.AddMvcCore()

View File

@ -220,7 +220,7 @@ namespace Kyoo.Core.Api
/// <response code="404">No item could be found with the given id or slug.</response>
[HttpDelete("{identifier:id}")]
[PartialPermission(Kind.Delete)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Identifier identifier)
{
@ -236,7 +236,7 @@ namespace Kyoo.Core.Api
return NotFound();
}
return Ok();
return NoContent();
}
/// <summary>
@ -250,7 +250,7 @@ namespace Kyoo.Core.Api
/// <response code="400">One or multiple filters are invalid.</response>
[HttpDelete]
[PartialPermission(Kind.Delete)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<IActionResult> Delete([FromQuery] Dictionary<string, string> where)
{
@ -263,7 +263,7 @@ namespace Kyoo.Core.Api
return BadRequest(new RequestError(ex.Message));
}
return Ok();
return NoContent();
}
}
}

View File

@ -37,7 +37,7 @@ namespace Kyoo.Core.Api
/// </summary>
[Route("subtitles")]
[Route("subtitle", Order = AlternativeRoute)]
[PartialPermission(nameof(SubtitleApi))]
[PartialPermission("subtitle")]
[ApiController]
[ApiDefinition("Subtitles", Group = WatchGroup)]
public class SubtitleApi : ControllerBase

View File

@ -161,7 +161,8 @@ namespace Kyoo.Host.Generic
.OrderByDescending(x => x.Priority);
using ILifetimeScope scope = container.BeginLifetimeScope(x =>
x.RegisterInstance(app).SingleInstance().ExternallyOwned());
x.RegisterInstance(app).SingleInstance().ExternallyOwned()
);
IServiceProvider provider = scope.Resolve<IServiceProvider>();
foreach (IStartupAction step in steps)
step.Run(provider);

View File

@ -60,17 +60,12 @@
},
"authentication": {
"certificate": {
"file": "certificate.pfx",
"oldFile": "oldCertificate.pfx",
"password": "passphrase"
},
"permissions": {
"default": ["overall.read", "overall.write", "overall.create", "overall.delete", "admin.read", "admin.write"],
"newUser": ["overall.read", "overall.write", "overall.create", "overall.delete", "admin.read", "admin.write"]
"default": ["overall.read", "overall.write"],
"newUser": ["overall.read", "overall.write"]
},
"profilePicturePath": "users/",
"clients": []
"secret": "4c@mraGB!KRfF@kpS8740y9FcHemKxBsqqxLbdR?"
},
"tvdb": {

View File

@ -16,6 +16,7 @@
// You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
@ -36,12 +37,19 @@ namespace Kyoo.Swagger
public bool Process(OperationProcessorContext context)
{
context.OperationDescription.Operation.Security ??= new List<OpenApiSecurityRequirement>();
OpenApiSecurityRequirement perms = context.MethodInfo.GetCustomAttributes<PermissionAttribute>()
OpenApiSecurityRequirement perms = context.MethodInfo.GetCustomAttributes<UserOnlyAttribute>()
.Aggregate(new OpenApiSecurityRequirement(), (agg, cur) =>
{
agg[nameof(Kyoo)] = Array.Empty<string>();
return agg;
});
perms = context.MethodInfo.GetCustomAttributes<PermissionAttribute>()
.Aggregate(perms, (agg, cur) =>
{
ICollection<string> permissions = _GetPermissionsList(agg, cur.Group);
permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}");
agg[cur.Group.ToString()] = permissions;
agg[nameof(Kyoo)] = permissions;
return agg;
});
@ -61,7 +69,7 @@ namespace Kyoo.Swagger
: cur.Kind;
ICollection<string> permissions = _GetPermissionsList(agg, group);
permissions.Add($"{type}.{kind.ToString().ToLower()}");
agg[group.ToString()] = permissions;
agg[nameof(Kyoo)] = permissions;
return agg;
});
}
@ -70,7 +78,7 @@ namespace Kyoo.Swagger
return true;
}
private ICollection<string> _GetPermissionsList(OpenApiSecurityRequirement security, Group group)
private static ICollection<string> _GetPermissionsList(OpenApiSecurityRequirement security, Group group)
{
return security.TryGetValue(group.ToString(), out IEnumerable<string> perms)
? perms.ToList()

View File

@ -120,35 +120,14 @@ namespace Kyoo.Swagger
}));
document.SchemaProcessors.Add(new ThumbnailProcessor());
document.AddSecurity("Kyoo", new OpenApiSecurityScheme
document.AddSecurity(nameof(Kyoo), new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.OpenIdConnect,
OpenIdConnectUrl = "/.well-known/openid-configuration",
Description = "You can login via an OIDC client, clients must be first registered in kyoo. " +
"Documentation coming soon."
Type = OpenApiSecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
Description = "The user's bearer"
});
document.OperationProcessors.Add(new OperationPermissionProcessor());
// This does not respect the swagger's specification but it works for swaggerUi and ReDoc so honestly this will do.
document.AddSecurity(Group.Overall.ToString(), new OpenApiSecurityScheme
{
ExtensionData = new Dictionary<string, object>
{
["type"] = "OpenID Connect or Api Key"
},
Description = "Kyoo's permissions work by groups. Permissions are attributed to " +
"a specific group and if a user has a group permission, it will be the same as having every " +
"permission in the group. For example, having overall.read gives you collections.read, " +
"shows.read and so on."
});
document.AddSecurity(Group.Admin.ToString(), new OpenApiSecurityScheme
{
ExtensionData = new Dictionary<string, object>
{
["type"] = "OpenID Connect or Api Key"
},
Description = "The permission group used for administrative items like tasks, account management " +
"and library creation."
});
});
}

View File

@ -1,90 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kyoo - Login</title>
<link rel="stylesheet" type="text/css" href="lib/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="login.css" />
<link rel="stylesheet" type="text/css" href="material-icons.css" />
<script src="lib/jquery.min.js"></script>
<script src="lib/bootstrap.min.js"></script>
</head>
<body style="height: 100vh; align-items: center;" class="d-flex">
<div class="container pb-5">
<div class="card bg-dark mb-5">
<div class="card-header bg-secondary text-white">
<ul class="nav nav-tabs card-header-tabs navbar-dark" role="tablist" id="tabs">
<li class="nav-item">
<a class="nav-link active" id="login-tab" data-toggle="tab" href="#login" role="tab" aria-selected="true">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#register" role="tab" aria-selected="false">Register</a>
</li>
</ul>
</div>
<div class="card-body tab-content">
<div class="tab-pane fade show active" id="login" role="tabpanel" aria-labelledby="login-tab">
<div class="alert alert-danger" role="alert" id="login-error" style="display: none;"></div>
<form>
<div class="form-group">
<label for="login-username">Username</label>
<input autocomplete="username" class="form-control" id="login-username">
</div>
<div class="form-group">
<label for="login-password">Password</label>
<div class="input-group">
<input autocomplete="current-password" type="password" class="form-control" id="login-password">
<div class="input-group-append password-visibility">
<a class="input-group-text" href="#"><i class="material-icons">visibility</i></a>
</div>
</div>
</div>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="remember-me">
<label class="form-check-label" for="remember-me">Remember me</label>
</div>
<button id="login-btn" type="submit" class="btn" style="background-color: #e23c00;">Login</button>
</form>
</div>
<div class="tab-pane fade" id="register" role="tabpanel" aria-labelledby="register-tab">
<div class="alert alert-danger" role="alert" id="register-error" style="display: none;"></div>
<form>
<div class="form-group">
<label for="register-email">Email</label>
<input type="email" class="form-control" id="register-email">
</div>
<div class="form-group">
<label for="register-username">Username</label>
<input autocomplete="username" class="form-control" id="register-username">
</div>
<div class="form-group">
<label for="register-password">Password</label>
<div class="input-group">
<input autocomplete="new-password" type="password" class="form-control" id="register-password">
<div class="input-group-append password-visibility">
<a class="input-group-text" href="#"><i class="material-icons">visibility</i></a>
</div>
</div>
</div>
<div class="form-group">
<label for="register-password-confirm">Confirm Password</label>
<div class="input-group">
<input autocomplete="new-password" type="password" class="form-control" id="register-password-confirm">
<div class="input-group-append password-visibility">
<a class="input-group-text" href="#"><i class="material-icons">visibility</i></a>
</div>
</div>
</div>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="stay-logged-in">
<label class="form-check-label" for="stay-logged-in">Stay logged in</label>
</div>
<button id="register-btn" type="submit" class="btn" style="background-color: #e23c00;">Register</button>
</form>
</div>
</div>
</div>
</div>
<script src="login.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,16 +0,0 @@
.nav-tabs .nav-link, a
{
color: white;
}
.nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link.active
{
background-color: #343a40;
color: white;
border-color: #343a40;
}
.form-control:focus
{
box-shadow: none;
}

View File

@ -1,127 +0,0 @@
$("#tabs a").on("click", function (e)
{
e.preventDefault();
$(this).tab("show");
});
$(".password-visibility a").on("click", function(e)
{
e.preventDefault();
let password = $(this).parent().siblings("input");
let toggle = $(this).children("i");
if (password.attr("type") === "text")
{
password.attr("type", "password");
toggle.text("visibility");
}
else
{
toggle.text("visibility_off");
password.attr("type", "text");
}
});
$("#login-btn").on("click", function (e)
{
e.preventDefault();
let user = {
username: $("#login-username")[0].value,
password: $("#login-password")[0].value,
stayLoggedIn: $("#remember-me")[0].checked
};
$.ajax(
{
url: "/api/account/login",
type: "POST",
contentType: 'application/json;charset=UTF-8',
data: JSON.stringify(user),
success: function ()
{
let returnUrl = new URLSearchParams(window.location.search).get("ReturnUrl");
if (returnUrl == null)
window.location.href = "/unauthorized";
else
window.location.href = returnUrl;
},
error: function(xhr)
{
let error = $("#login-error");
error.show();
error.text(JSON.parse(xhr.responseText)[0].description);
}
});
});
$("#register-btn").on("click", function (e)
{
e.preventDefault();
let user = {
email: $("#register-email")[0].value,
username: $("#register-username")[0].value,
password: $("#register-password")[0].value
};
if (user.password !== $("#register-password-confirm")[0].value)
{
let error = $("#register-error");
error.show();
error.text("Passwords don't match.");
return;
}
$.ajax(
{
url: "/api/account/register",
type: "POST",
contentType: 'application/json;charset=UTF-8',
dataType: 'json',
data: JSON.stringify(user),
success: function(res)
{
useOtac(res.otac);
},
error: function(xhr)
{
let error = $("#register-error");
error.show();
error.html(Object.values(JSON.parse(xhr.responseText).errors).map(x => x[0]).join("<br/>"));
}
});
});
function useOtac(otac)
{
$.ajax(
{
url: "/api/account/otac-login",
type: "POST",
contentType: 'application/json;charset=UTF-8',
data: JSON.stringify({otac: otac, stayLoggedIn: $("#stay-logged-in")[0].checked}),
success: function()
{
let returnUrl = new URLSearchParams(window.location.search).get("ReturnUrl");
if (returnUrl == null)
window.location.href = "/unauthorized";
else
window.location.href = returnUrl;
},
error: function(xhr)
{
let error = $("#register-error");
error.show();
error.text(JSON.parse(xhr.responseText)[0].description);
}
});
}
let otac = new URLSearchParams(window.location.search).get("otac");
if (otac != null)
useOtac(otac);

View File

@ -1,36 +0,0 @@
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url(/iconfont/MaterialIcons-Regular.eot); /* For IE6-8 */
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(/iconfont/MaterialIcons-Regular.woff2) format('woff2'),
url(/iconfont/MaterialIcons-Regular.woff) format('woff'),
url(/iconfont/MaterialIcons-Regular.ttf) format('truetype');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px; /* Preferred icon size */
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Safari and Chrome. */
text-rendering: optimizeLegibility;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
/* Support for IE. */
font-feature-settings: 'liga';
}

1
tests/robot/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
venv/

View File

@ -0,0 +1,83 @@
*** Settings ***
Documentation Tests of the /auth route.
... Ensures that the user can authenticate on kyoo.
Resource ../rest.resource
*** Keywords ***
Login
[Documentation] Shortcut to login with the given username for future requests
[Arguments] ${username}
&{res}= POST /auth/login {"username": "${username}", "password": "password-${username}"}
Output
Integer response status 200
String response body access_token
Set Headers {"Authorization": "Bearer ${res.body.access_token}"}
Register
[Documentation] Shortcut to register with the given username for future requests
[Arguments] ${username}
&{res}= POST
... /auth/register
... {"username": "${username}", "password": "password-${username}", "email": "${username}@kyoo.moe"}
Output
Integer response status 200
String response body access_token
Set Headers {"Authorization": "Bearer ${res.body.access_token}"}
Logout
[Documentation] Logout the current user, only the local client is affected.
Set Headers {"Authorization": ""}
*** Test Cases ***
Me cant be accessed without an account
Get /auth/me
Output
Integer response status 403
Bad Account
[Documentation] Login fails if user does not exist
POST /auth/login {"username": "i-don-t-exist", "password": "pass"}
Output
Integer response status 403
Register
[Documentation] Create a new user and login in it
Register user-1
[Teardown] DELETE /auth/me
Register Duplicates
[Documentation] If two users tries to register with the same username, it fails
Register user-duplicate
# We can't use the `Register` keyword because it assert for success
POST /auth/register {"username": "user-duplicate", "password": "pass", "email": "mail@kyoo.moe"}
Output
Integer response status 409
[Teardown] DELETE /auth/me
Delete Account
[Documentation] Check if a user can delete it's account
Register I-should-be-deleted
DELETE /auth/me
Output
Integer response status 204
Login
[Documentation] Create a new user and login in it
Register login-user
${res}= GET /auth/me
Output
Integer response status 200
String response body username login-user
Logout
Login login-user
${me}= Get /auth/me
Output
Output ${me}
Should Be Equal As Strings ${res["body"]} ${me["body"]}
[Teardown] DELETE /auth/me

View File

@ -0,0 +1,4 @@
[tool.robotidy]
configure = [
"MergeAndOrderSections:order=comments,settings,keywords,variables,testcases"
]

View File

@ -0,0 +1,2 @@
robotframework
RESTinstance

View File

@ -0,0 +1,4 @@
*** Settings ***
Documentation Common things to handle rest requests
Library REST http://localhost:5000/api