Rewoking the login workflow

This commit is contained in:
Zoe Roux
2020-04-12 03:32:09 +02:00
parent 62753b20bb
commit 4bdaba4643
16 changed files with 365 additions and 42 deletions
+26 -26
View File
@@ -62,38 +62,38 @@ namespace Kyoo.Controllers
{
SecureRandom random = new SecureRandom();
X509V3CertificateGenerator certificateGenerator = new X509V3CertificateGenerator();
certificateGenerator.SetSerialNumber(BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(Int64.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));
X509V3CertificateGenerator certificateGenerator = new X509V3CertificateGenerator();
certificateGenerator.SetSerialNumber(BigIntegers.CreateRandomInRange(BigInteger.One, BigInteger.ValueOf(Int64.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 KeyGenerationParameters(random, 2048);
RsaKeyPairGenerator keyPairGenerator = new RsaKeyPairGenerator();
keyPairGenerator.Init(keyGenerationParameters);
KeyGenerationParameters keyGenerationParameters = new KeyGenerationParameters(random, 2048);
RsaKeyPairGenerator keyPairGenerator = new RsaKeyPairGenerator();
keyPairGenerator.Init(keyGenerationParameters);
AsymmetricCipherKeyPair subjectKeyPair = keyPairGenerator.GenerateKeyPair();
certificateGenerator.SetPublicKey(subjectKeyPair.Public);
AsymmetricCipherKeyPair subjectKeyPair = keyPairGenerator.GenerateKeyPair();
certificateGenerator.SetPublicKey(subjectKeyPair.Public);
AsymmetricCipherKeyPair issuerKeyPair = subjectKeyPair;
const string signatureAlgorithm = "MD5WithRSA";
Asn1SignatureFactory signatureFactory = new Asn1SignatureFactory(signatureAlgorithm, issuerKeyPair.Private);
X509Certificate bouncyCert = certificateGenerator.Generate(signatureFactory);
AsymmetricCipherKeyPair issuerKeyPair = subjectKeyPair;
const string signatureAlgorithm = "MD5WithRSA";
Asn1SignatureFactory signatureFactory = new Asn1SignatureFactory(signatureAlgorithm, issuerKeyPair.Private);
X509Certificate bouncyCert = certificateGenerator.Generate(signatureFactory);
X509Certificate2 certificate;
X509Certificate2 certificate;
Pkcs12Store store = new Pkcs12StoreBuilder().Build();
store.SetKeyEntry("Kyoo_key", new AsymmetricKeyEntry(subjectKeyPair.Private), new [] {new X509CertificateEntry(bouncyCert)});
Pkcs12Store store = new Pkcs12StoreBuilder().Build();
store.SetKeyEntry("Kyoo_key", new AsymmetricKeyEntry(subjectKeyPair.Private), new [] {new X509CertificateEntry(bouncyCert)});
using (MemoryStream pfxStream = new MemoryStream())
{
store.Save(pfxStream, password.ToCharArray(), random);
certificate = new X509Certificate2(pfxStream.ToArray(), password, X509KeyStorageFlags.Exportable);
using FileStream fileStream = File.OpenWrite(file);
pfxStream.WriteTo(fileStream);
}
return certificate;
using (MemoryStream pfxStream = new MemoryStream())
{
store.Save(pfxStream, password.ToCharArray(), random);
certificate = new X509Certificate2(pfxStream.ToArray(), password, X509KeyStorageFlags.Exportable);
using FileStream fileStream = File.OpenWrite(file);
pfxStream.WriteTo(fileStream);
}
return certificate;
}
}
+9 -4
View File
@@ -6,6 +6,7 @@
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<IsPackable>false</IsPackable>
<SpaRoot>Views/WebClient/</SpaRoot>
<LoginRoot>Views/Login/</LoginRoot>
<TranscoderRoot>../transcoder</TranscoderRoot>
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules/**</DefaultItemExcludes>
@@ -44,6 +45,8 @@
<Content Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
<StaticFiles Include="$(SpaRoot)static/**" />
<LoginFiles Include="$(LoginRoot)**" />
<StaticFiles Include="Views\Login\material-icons.css" />
</ItemGroup>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
@@ -64,11 +67,17 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
<ResolvedFileToPublish Include="@(LoginFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>login/%(LoginFiles.RecursiveDir)%(LoginFiles.Filename)%(LoginFiles.Extension)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<Target Name="Prepare the web app" AfterTargets="Build" Condition="$(Configuration) == 'Debug'">
<Copy SourceFiles="@(StaticFiles)" DestinationFolder="$(OutputPath)/wwwroot/%(RecursiveDir)" />
<Copy SourceFiles="@(LoginFiles)" DestinationFolder="$(OutputPath)/login/%(RecursiveDir)" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target>
@@ -87,8 +96,4 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="CreatePluginFolder" AfterTargets="Build">
<MakeDir Directories="$(OutputPath)/plugins" />
</Target>
</Project>
+6 -4
View File
@@ -22,14 +22,16 @@ namespace Kyoo
new Client
{
ClientId = "kyoo.webapp",
AccessTokenType = AccessTokenType.Jwt,
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
AllowAccessTokensViaBrowser = true,
AlwaysIncludeUserClaimsInIdToken = true,
AllowOfflineAccess = true,
RequireClientSecret = false,
AllowAccessTokensViaBrowser = true,
AllowOfflineAccess = true,
RequireConsent = false,
AccessTokenType = AccessTokenType.Jwt,
AllowedScopes = { "openid", "profile", "kyoo.read", "kyoo.write", "kyoo.play", "kyoo.download", "kyoo.admin" },
RedirectUris = { "/", "/silent" },
PostLogoutRedirectUris = { "/logout" }
+1 -1
View File
@@ -15,7 +15,7 @@ namespace Kyoo
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseKestrel((config) => { config.AddServerHeader = false; })
.UseKestrel(config => { config.AddServerHeader = false; })
.UseUrls("http://*:5000")
.UseStartup<Startup>();
}
+7 -2
View File
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.SpaServices.AngularCli;
using Microsoft.EntityFrameworkCore;
@@ -88,7 +89,7 @@ namespace Kyoo
options.Audience = "Kyoo";
options.RequireHttpsMetadata = false;
});
services.AddAuthorization(options =>
{
AuthorizationPolicyBuilder scheme = new AuthorizationPolicyBuilder(IdentityConstants.ApplicationScheme, JwtBearerDefaults.AuthenticationScheme);
@@ -145,7 +146,11 @@ namespace Kyoo
app.UseAuthentication();
app.UseIdentityServer();
app.UseAuthorization();
app.UseCookiePolicy(new CookiePolicyOptions
{
MinimumSameSitePolicy = SameSiteMode.Lax
});
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute("Kyoo", "api/{controller=Home}/{action=Index}/{id?}");
+34 -3
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Security.Claims;
@@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
@@ -32,7 +34,8 @@ namespace Kyoo.Api
public class OtacRequest
{
public string Otac;
public string Otac { get; set; }
public bool StayLoggedIn { get; set; }
}
public class AccountData
@@ -45,6 +48,28 @@ namespace Kyoo.Api
public IFormFile Picture { get; set; }
}
[ApiController]
public class AccountUiController : Controller
{
[HttpGet("login")]
public IActionResult Index()
{
return new PhysicalFileResult(Path.GetFullPath("login/login.html"), "text/html");
}
[HttpGet("login/{*file}")]
public IActionResult Index(string file)
{
string path = Path.Combine(Path.GetFullPath("login/"), file);
if (!System.IO.File.Exists(path))
return NotFound();
FileExtensionContentTypeProvider provider = new FileExtensionContentTypeProvider();
if (!provider.TryGetContentType(path, out string contentType))
contentType = "text/plain";
return new PhysicalFileResult(path, contentType);
}
}
[Route("api/[controller]")]
[ApiController]
public class AccountController : Controller, IProfileService
@@ -74,6 +99,10 @@ namespace Kyoo.Api
{
if (!ModelState.IsValid)
return BadRequest(user);
if (user.Username.Length < 4)
return BadRequest(new[] {new {code = "username", description = "Username must be at least 4 characters."}});
if (!new EmailAddressAttribute().IsValid(user.Email))
return BadRequest(new[] {new {code = "email", description = "Email must be valid."}});
User account = new User {UserName = user.Username, Email = user.Email};
IdentityResult result = await _userManager.CreateAsync(account, user.Password);
if (!result.Succeeded)
@@ -81,7 +110,7 @@ namespace Kyoo.Api
string otac = account.GenerateOTAC(TimeSpan.FromMinutes(1));
await _userManager.UpdateAsync(account);
await _userManager.AddClaimsAsync(account, defaultClaims);
return Ok(otac);
return Ok(new {otac});
}
[HttpPost("login")]
@@ -98,12 +127,14 @@ namespace Kyoo.Api
[HttpPost("otac-login")]
public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac)
{
if (!ModelState.IsValid)
return BadRequest(otac);
User user = _userManager.Users.FirstOrDefault(x => x.OTAC == otac.Otac);
if (user == null)
return BadRequest(new [] { new {code = "InvalidOTAC", description = "No user was found for this OTAC."}});
if (user.OTACExpires <= DateTime.UtcNow)
return BadRequest(new [] { new {code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password."}});
await _signInManager.SignInAsync(user, true);
await _signInManager.SignInAsync(user, otac.StayLoggedIn);
return Ok();
}
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
+16
View File
@@ -0,0 +1,16 @@
.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;
}
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kyoo - Login</title>
<link rel="stylesheet" type="text/css" href="login/lib/bootstrap.min.css" />
<link rel="stylesheet" type="text/css" href="login/login.css" />
<link rel="stylesheet" type="text/css" href="login/material-icons.css" />
<script src="login/lib/jquery.min.js"></script>
<script src="login/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/login.js"></script>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
$("#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.text(JSON.parse(xhr.responseText)[0].description);
}
});
});
function useOtac(otac)
{
$.ajax(
{
url: "/api/account/otac-login",
type: "POST",
contentType: 'application/json;charset=UTF-8',
data: JSON.stringify({otac: otac, tayLoggedIn: $("#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);
+36
View File
@@ -0,0 +1,36 @@
@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
View File
@@ -1,6 +1,7 @@
{
"server.urls": "http://0.0.0.0:5000",
"public_url": "http://localhost:5000/",
"http_port": 5000,
"https_port": 44300,
"Logging": {
"LogLevel": {