First Working Draft of JWT Login System

This commit is contained in:
Elia Zammuto
2024-03-10 22:04:24 +01:00
committed by TheElixZammuto
parent 8316f44e10
commit 9ef63ca829
16 changed files with 275 additions and 16 deletions
+3
View File
@@ -42,3 +42,6 @@
path = third-party/wlr-protocols
url = https://gitlab.freedesktop.org/wlroots/wlr-protocols
branch = master
[submodule "third-party/jwt-cpp"]
path = third-party/jwt-cpp
url = https://github.com/Thalhammer/jwt-cpp.git
+1
View File
@@ -130,6 +130,7 @@ include_directories(
"${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/enet/include"
"${CMAKE_SOURCE_DIR}/third-party/nanors"
"${CMAKE_SOURCE_DIR}/third-party/nanors/deps/obl"
"${CMAKE_SOURCE_DIR}/third-party/jwt-cpp/include"
${FFMPEG_INCLUDE_DIRS}
${PLATFORM_INCLUDE_DIRS}
)
+110 -16
View File
@@ -32,6 +32,7 @@
#include "file_handler.h"
#include "globals.h"
#include "httpcommon.h"
#include "jwt-cpp/jwt.h"
#include "logging.h"
#include "network.h"
#include "nvhttp.h"
@@ -46,6 +47,8 @@ using namespace std::literals;
namespace confighttp {
namespace fs = std::filesystem;
namespace pt = boost::property_tree;
std::string jwt_key;
using https_server_t = SimpleWeb::Server<SimpleWeb::HTTPS>;
@@ -64,7 +67,7 @@ namespace confighttp {
BOOST_LOG(debug) << "DESTINATION :: "sv << request->path;
for (auto &[name, val] : request->header) {
BOOST_LOG(debug) << name << " -- " << (name == "Authorization" ? "CREDENTIALS REDACTED" : val);
BOOST_LOG(debug) << name << " -- " << (name == "Cookie" ? "COOKIES REDACTED" : val);
}
BOOST_LOG(debug) << " [--] "sv;
@@ -80,9 +83,7 @@ namespace confighttp {
send_unauthorized(resp_https_t response, req_https_t request) {
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
BOOST_LOG(info) << "Web UI: ["sv << address << "] -- not authorized"sv;
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "WWW-Authenticate", R"(Basic realm="Sunshine Gamestream Host", charset="UTF-8")" }
};
const SimpleWeb::CaseInsensitiveMultimap headers {};
response->write(SimpleWeb::StatusCode::client_error_unauthorized, headers);
}
@@ -114,29 +115,48 @@ namespace confighttp {
}
auto fg = util::fail_guard([&]() {
send_unauthorized(response, request);
BOOST_LOG(info) << request->path;
std::string apiPrefix = "/api";
if (request->path.compare(0, apiPrefix.length(), apiPrefix) == 0) {
send_unauthorized(response, request);
}
else {
send_redirect(response, request, "/login");
}
});
auto auth = request->header.find("authorization");
auto auth = request->header.find("cookie");
if (auth == request->header.end()) {
return false;
}
auto &rawAuth = auth->second;
auto authData = SimpleWeb::Crypto::Base64::decode(rawAuth.substr("Basic "sv.length()));
std::istringstream iss(rawAuth);
std::string token, cookie_name = "sunshine_session=", cookie_value = "";
int index = authData.find(':');
if (index >= authData.size() - 1) {
return false;
while (std::getline(iss, token, ';')) {
BOOST_LOG(info) << token;
// Left Trim Cookie
token.erase(token.begin(), std::find_if(token.begin(), token.end(), [](unsigned char ch) {
return !std::isspace(ch);
}));
// Compare that the cookie name is sunshine_session
if (token.compare(0, cookie_name.length(), cookie_name) == 0) {
cookie_value = token.substr(cookie_name.length());
BOOST_LOG(info) << cookie_value;
break;
}
}
auto username = authData.substr(0, index);
auto password = authData.substr(index + 1);
auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string();
if (cookie_value.length() == 0) return false;
BOOST_LOG(info) << "JWT: " << cookie_value;
auto decoded = jwt::decode(cookie_value);
auto verifier = jwt::verify()
.with_issuer("sunshine-" + http::unique_id)
.with_claim("sub", jwt::claim(std::string(config::sunshine.username)))
.allow_algorithm(jwt::algorithm::hs256 { jwt_key });
if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) {
return false;
}
verifier.verify(decoded);
fg.disable();
return true;
@@ -181,6 +201,16 @@ namespace confighttp {
response->write(content, headers);
}
void
getLoginPage(resp_https_t response, req_https_t request) {
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "login.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
}
void
getAppsPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
@@ -720,16 +750,79 @@ namespace confighttp {
outputTree.put("status", true);
}
void
login(resp_https_t response, req_https_t request) {
auto address = net::addr_to_normalized_string(request->remote_endpoint().address());
auto ip_type = net::from_address(address);
if (ip_type > http::origin_web_ui_allowed) {
BOOST_LOG(info) << "Web UI: ["sv << address << "] -- denied"sv;
response->write(SimpleWeb::StatusCode::client_error_forbidden);
return;
}
std::stringstream ss;
ss << request->content.rdbuf();
pt::ptree inputTree, outputTree;
auto g = util::fail_guard([&]() {
std::ostringstream data;
pt::write_json(data, outputTree);
response->write(data.str());
});
try {
// TODO: Input Validation
pt::read_json(ss, inputTree);
auto username = inputTree.get<std::string>("username");
auto password = inputTree.get<std::string>("password");
auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string();
if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) {
outputTree.put("status", "false");
return;
}
outputTree.put("status", "true");
auto token = jwt::create().set_type("JWS").set_issued_now().set_expires_in(std::chrono::seconds { 3600 }).set_issuer("sunshine-" + http::unique_id).set_payload_claim("sub", jwt::claim(std::string(config::sunshine.username))).sign(jwt::algorithm::hs256 { jwt_key });
std::stringstream cookie_stream;
cookie_stream << "sunshine_session=";
cookie_stream << token;
cookie_stream << "; Secure; HttpOnly; SameSite=Strict; Path=/";
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Set-Cookie", cookie_stream.str() }
};
std::ostringstream data;
pt::write_json(data, outputTree);
response->write(SimpleWeb::StatusCode::success_ok, data.str(), headers);
g.disable();
return;
}
catch (std::exception &e) {
BOOST_LOG(warning) << "SaveApp: "sv << e.what();
outputTree.put("status", "false");
outputTree.put("error", "Invalid Input JSON");
return;
}
outputTree.put("status", "true");
}
void
start() {
auto shutdown_event = mail::man->event<bool>(mail::shutdown);
//On each server start, create a randomized jwt_key
jwt_key = crypto::rand_alphabet(64);
auto port_https = net::map_port(PORT_HTTPS);
auto address_family = net::af_from_enum_string(config::sunshine.address_family);
https_server_t server { config::nvhttp.cert, config::nvhttp.pkey };
server.default_resource["GET"] = not_found;
server.resource["^/$"]["GET"] = getIndexPage;
server.resource["^/login/?$"]["GET"] = getLoginPage;
server.resource["^/pin/?$"]["GET"] = getPinPage;
server.resource["^/apps/?$"]["GET"] = getAppsPage;
server.resource["^/clients/?$"]["GET"] = getClientsPage;
@@ -749,6 +842,7 @@ namespace confighttp {
server.resource["^/api/clients/unpair$"]["POST"] = unpairAll;
server.resource["^/api/apps/close$"]["POST"] = closeApp;
server.resource["^/api/covers/upload$"]["POST"] = uploadCover;
server.resource["^/api/login$"]["POST"] = login;
server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage;
server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getSunshineLogoImage;
server.resource["^/assets\\/.+$"]["GET"] = getNodeModules;
@@ -0,0 +1,61 @@
<template>
<form @submit.prevent="save">
<div class="mb-2">
<label for="usernameInput" class="form-label">Username:</label>
<input type="text" class="form-control" id="usernameInput" autocomplete="username"
v-model="passwordData.username" />
</div>
<div class="mb-2">
<label for="passwordInput" class="form-label">Password:</label>
<input type="password" class="form-control" id="passwordInput" autocomplete="new-password"
v-model="passwordData.password" required />
</div>
<button type="submit" class="btn btn-primary w-100 mb-2" v-bind:disabled="loading">
Login
</button>
<div class="alert alert-danger" v-if="error"><b>Error: </b>{{ error }}</div>
<div class="alert alert-success" v-if="success">
<b>Success! </b>
</div>
</form>
</template>
<script>
export default {
data() {
return {
error: null,
success: false,
loading: false,
passwordData: {
username: "",
password: ""
},
};
},
methods: {
save() {
this.error = null;
this.loading = true;
fetch("/api/login", {
method: "POST",
body: JSON.stringify(this.passwordData),
}).then((r) => {
this.loading = false;
if (r.status == 200) {
r.json().then((rj) => {
if (rj.status.toString() === "true") {
this.success = true;
this.$emit('loggedin');
} else {
this.error = rj.error || "Invalid Username or Password";
}
});
} else {
this.error = "Internal Server Error";
}
});
},
},
}
</script>
+30
View File
@@ -32,10 +32,31 @@
</div>
</div>
</nav>
<!-- Modal that is shown when the user gets a 401 error -->
<div class="modal fade" id="loginModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="exampleModalLabel">Session Expired</h1>
</div>
<div class="modal-body">
<LoginForm @loggedin="onLogin" />
</div>
</div>
</div>
</div>
</template>
<script>
import {Modal} from 'bootstrap';
import LoginForm from './LoginForm.vue'
export default {
components: {
LoginForm
},
data(){
modal: null
},
created() {
console.log("Header mounted!")
},
@@ -45,6 +66,15 @@ export default {
let discordWidget = document.createElement('script')
discordWidget.setAttribute('src', 'https://app.lizardbyte.dev/js/discord.js')
document.head.appendChild(discordWidget)
window.addEventListener("sunshine:session_expire", () => {
this.modal.toggle();
})
this.modal = new Modal(document.getElementById('loginModal'), {});
},
methods: {
onLogin(){
this.modal.toggle();
}
}
}
</script>
+1
View File
@@ -397,6 +397,7 @@
import { createApp } from 'vue';
import Navbar from './Navbar.vue'
import {Dropdown} from 'bootstrap'
import fetch from './fetch.js'
const app = createApp({
components: {
Navbar
+1
View File
@@ -1177,6 +1177,7 @@
<script type="module">
import { createApp } from 'vue'
import Navbar from './Navbar.vue'
import fetch from './fetch.js'
const app = createApp({
components: {
+9
View File
@@ -0,0 +1,9 @@
export default async (url,config) => {
const response = await fetch(url, config);
console.log(response);
if(response.status == 401){
const event = new Event("sunshine:session_expire");
window.dispatchEvent(event);
}
return response;
};
+1
View File
@@ -71,6 +71,7 @@
import { createApp } from 'vue'
import Navbar from './Navbar.vue'
import ResourceCard from './ResourceCard.vue'
import fetch from './fetch.js'
console.log("Hello, Sunshine!")
let app = createApp({
components: {
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- header %>
</head>
<body id="app">
<main role="main" style="max-width: 1200px; margin: 1em auto">
<div class="d-flex justify-content-center gap-4">
<div class="card p-4">
<header>
<h1 class="mb-0">
<img src="/images/logo-sunshine-45.png" height="45" alt="" style="vertical-align: bottom;">
Welcome to Sunshine!
</h1>
</header>
<Login-Form @loggedin="onLogin"></Login-Form>
<Resource-Card />
</div>
</div>
</main>
</body>
<script type="module">
import { createApp } from "vue"
import ResourceCard from './ResourceCard.vue'
import LoginForm from './LoginForm.vue'
let app = createApp({
components: {
'ResourceCard': ResourceCard,
'LoginForm': LoginForm
},
data(){
return {
a: 1
}
},
mounted(){
console.log("Ciao!",this.$el)
},
methods: {
onLogin() {
document.location.href = '/';
}
}
});
console.log("App",app);
app.mount("#app");
</script>
@@ -71,6 +71,7 @@
<script type="module">
import { createApp } from 'vue'
import Navbar from './Navbar.vue'
import fetch from './fetch.js'
const app = createApp({
components: {
+2
View File
@@ -27,6 +27,8 @@
<script type="module">
import Navbar from './Navbar.vue'
import {createApp} from 'vue'
import fetch from './fetch.js'
let app = createApp({
components: {
Navbar
@@ -121,6 +121,7 @@
<script type="module">
import { createApp } from 'vue'
import Navbar from './Navbar.vue'
import fetch from './fetch.js'
const app = createApp({
components: {
@@ -58,6 +58,8 @@
<script type="module">
import { createApp } from "vue"
import ResourceCard from './ResourceCard.vue'
import fetch from './fetch.js'
let app = createApp({
components: {
ResourceCard
Vendored Submodule
+1
Submodule third-party/jwt-cpp added at 364a5572f4
+1
View File
@@ -47,6 +47,7 @@ export default defineConfig({
input: {
apps: resolve(assetsSrcPath, 'apps.html'),
config: resolve(assetsSrcPath, 'config.html'),
login: resolve(assetsSrcPath, 'login.html'),
index: resolve(assetsSrcPath, 'index.html'),
password: resolve(assetsSrcPath, 'password.html'),
pin: resolve(assetsSrcPath, 'pin.html'),