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

View File

@ -6,6 +6,7 @@
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion> <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<SpaRoot>Views/WebClient/</SpaRoot> <SpaRoot>Views/WebClient/</SpaRoot>
<LoginRoot>Views/Login/</LoginRoot>
<TranscoderRoot>../transcoder</TranscoderRoot> <TranscoderRoot>../transcoder</TranscoderRoot>
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules/**</DefaultItemExcludes> <DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules/**</DefaultItemExcludes>
@ -44,6 +45,8 @@
<Content Remove="$(SpaRoot)**" /> <Content Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" /> <None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
<StaticFiles Include="$(SpaRoot)static/**" /> <StaticFiles Include="$(SpaRoot)static/**" />
<LoginFiles Include="$(LoginRoot)**" />
<StaticFiles Include="Views\Login\material-icons.css" />
</ItemGroup> </ItemGroup>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish"> <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
@ -64,11 +67,17 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile> <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish> </ResolvedFileToPublish>
<ResolvedFileToPublish Include="@(LoginFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>login/%(LoginFiles.RecursiveDir)%(LoginFiles.Filename)%(LoginFiles.Extension)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
</ResolvedFileToPublish>
</ItemGroup> </ItemGroup>
</Target> </Target>
<Target Name="Prepare the web app" AfterTargets="Build" Condition="$(Configuration) == 'Debug'"> <Target Name="Prepare the web app" AfterTargets="Build" Condition="$(Configuration) == 'Debug'">
<Copy SourceFiles="@(StaticFiles)" DestinationFolder="$(OutputPath)/wwwroot/%(RecursiveDir)" /> <Copy SourceFiles="@(StaticFiles)" DestinationFolder="$(OutputPath)/wwwroot/%(RecursiveDir)" />
<Copy SourceFiles="@(LoginFiles)" DestinationFolder="$(OutputPath)/login/%(RecursiveDir)" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" /> <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
</Target> </Target>
@ -87,8 +96,4 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
</ItemGroup> </ItemGroup>
<Target Name="CreatePluginFolder" AfterTargets="Build">
<MakeDir Directories="$(OutputPath)/plugins" />
</Target>
</Project> </Project>

View File

@ -22,14 +22,16 @@ namespace Kyoo
new Client new Client
{ {
ClientId = "kyoo.webapp", ClientId = "kyoo.webapp",
AccessTokenType = AccessTokenType.Jwt,
AllowedGrantTypes = GrantTypes.Code, AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true, RequirePkce = true,
AllowAccessTokensViaBrowser = true,
AlwaysIncludeUserClaimsInIdToken = true,
AllowOfflineAccess = true,
RequireClientSecret = false, RequireClientSecret = false,
AllowAccessTokensViaBrowser = true,
AllowOfflineAccess = true,
RequireConsent = false, RequireConsent = false,
AccessTokenType = AccessTokenType.Jwt,
AllowedScopes = { "openid", "profile", "kyoo.read", "kyoo.write", "kyoo.play", "kyoo.download", "kyoo.admin" }, AllowedScopes = { "openid", "profile", "kyoo.read", "kyoo.write", "kyoo.play", "kyoo.download", "kyoo.admin" },
RedirectUris = { "/", "/silent" }, RedirectUris = { "/", "/silent" },
PostLogoutRedirectUris = { "/logout" } PostLogoutRedirectUris = { "/logout" }

View File

@ -15,7 +15,7 @@ namespace Kyoo
public static IWebHostBuilder CreateWebHostBuilder(string[] args) => public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args) WebHost.CreateDefaultBuilder(args)
.UseKestrel((config) => { config.AddServerHeader = false; }) .UseKestrel(config => { config.AddServerHeader = false; })
.UseUrls("http://*:5000") .UseUrls("http://*:5000")
.UseStartup<Startup>(); .UseStartup<Startup>();
} }

View File

@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.SpaServices.AngularCli; using Microsoft.AspNetCore.SpaServices.AngularCli;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -145,6 +146,10 @@ namespace Kyoo
app.UseAuthentication(); app.UseAuthentication();
app.UseIdentityServer(); app.UseIdentityServer();
app.UseAuthorization(); app.UseAuthorization();
app.UseCookiePolicy(new CookiePolicyOptions
{
MinimumSameSitePolicy = SameSiteMode.Lax
});
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult; using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
@ -32,7 +34,8 @@ namespace Kyoo.Api
public class OtacRequest public class OtacRequest
{ {
public string Otac; public string Otac { get; set; }
public bool StayLoggedIn { get; set; }
} }
public class AccountData public class AccountData
@ -45,6 +48,28 @@ namespace Kyoo.Api
public IFormFile Picture { get; set; } 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]")] [Route("api/[controller]")]
[ApiController] [ApiController]
public class AccountController : Controller, IProfileService public class AccountController : Controller, IProfileService
@ -74,6 +99,10 @@ namespace Kyoo.Api
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
return BadRequest(user); 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}; User account = new User {UserName = user.Username, Email = user.Email};
IdentityResult result = await _userManager.CreateAsync(account, user.Password); IdentityResult result = await _userManager.CreateAsync(account, user.Password);
if (!result.Succeeded) if (!result.Succeeded)
@ -81,7 +110,7 @@ namespace Kyoo.Api
string otac = account.GenerateOTAC(TimeSpan.FromMinutes(1)); string otac = account.GenerateOTAC(TimeSpan.FromMinutes(1));
await _userManager.UpdateAsync(account); await _userManager.UpdateAsync(account);
await _userManager.AddClaimsAsync(account, defaultClaims); await _userManager.AddClaimsAsync(account, defaultClaims);
return Ok(otac); return Ok(new {otac});
} }
[HttpPost("login")] [HttpPost("login")]
@ -98,12 +127,14 @@ namespace Kyoo.Api
[HttpPost("otac-login")] [HttpPost("otac-login")]
public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac) public async Task<IActionResult> OtacLogin([FromBody] OtacRequest otac)
{ {
if (!ModelState.IsValid)
return BadRequest(otac);
User user = _userManager.Users.FirstOrDefault(x => x.OTAC == otac.Otac); User user = _userManager.Users.FirstOrDefault(x => x.OTAC == otac.Otac);
if (user == null) if (user == null)
return BadRequest(new [] { new {code = "InvalidOTAC", description = "No user was found for this OTAC."}}); return BadRequest(new [] { new {code = "InvalidOTAC", description = "No user was found for this OTAC."}});
if (user.OTACExpires <= DateTime.UtcNow) if (user.OTACExpires <= DateTime.UtcNow)
return BadRequest(new [] { new {code = "ExpiredOTAC", description = "The OTAC has expired. Try to login with your password."}}); 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(); return Ok();
} }

File diff suppressed because one or more lines are too long

7
Kyoo/Views/Login/lib/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
Kyoo/Views/Login/lib/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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;
}

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
Kyoo/Views/Login/login.js Normal file
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);

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 +1 @@
Subproject commit 3f56ef31b8e2cc86d806ef422885ae5c1b802898 Subproject commit f5deefafa4d30d1259c44166e23ad5f806ec0768

View File

@ -1,6 +1,7 @@
{ {
"server.urls": "http://0.0.0.0:5000", "server.urls": "http://0.0.0.0:5000",
"public_url": "http://localhost:5000/", "public_url": "http://localhost:5000/",
"http_port": 5000,
"https_port": 44300, "https_port": 44300,
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {

@ -1 +1 @@
Subproject commit a8e443545aa2963d995b6b2937d61a77620899b0 Subproject commit 59da0095a85914eabde9900973331a25a16088b3