Compare commits

...

6 Commits

Author SHA1 Message Date
TheElixZammuto
3888ec8da0 Merge branch 'nightly' into feat/new-session-system 2024-04-27 15:09:45 +02:00
Elia Zammuto
8ba64ffa32 Made C++ format Happy 2024-03-17 16:33:30 +01:00
Elia Zammuto
968b7963ee Migrated jwt-cpp to stable release, style fixes 2024-03-17 16:33:30 +01:00
Elia Zammuto
61df838356 clang-format 2024-03-17 16:33:30 +01:00
Elia Zammuto
d1845df0ea Logout 2024-03-17 16:33:30 +01:00
Elia Zammuto
9ef63ca829 First Working Draft of JWT Login System 2024-03-17 16:33:30 +01:00
16 changed files with 313 additions and 16 deletions

4
.gitmodules vendored
View File

@@ -14,6 +14,10 @@
path = third-party/googletest
url = https://github.com/google/googletest/
branch = v1.14.x
[submodule "third-party/jwt-cpp"]
path = third-party/jwt-cpp
url = https://github.com/Thalhammer/jwt-cpp.git
branch = master
[submodule "third-party/moonlight-common-c"]
path = third-party/moonlight-common-c
url = https://github.com/moonlight-stream/moonlight-common-c.git

View File

@@ -125,6 +125,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}
)

View File

@@ -25,6 +25,7 @@
#include <Simple-Web-Server/crypto.hpp>
#include <Simple-Web-Server/server_https.hpp>
#include <boost/asio/ssl/context_base.hpp>
#include <jwt-cpp/jwt.h>
#include "config.h"
#include "confighttp.h"
@@ -47,6 +48,8 @@ namespace confighttp {
namespace fs = std::filesystem;
namespace pt = boost::property_tree;
std::string jwt_key;
using https_server_t = SimpleWeb::Server<SimpleWeb::HTTPS>;
using args_t = SimpleWeb::CaseInsensitiveMultimap;
@@ -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" || name == "Authorization" ? "SENSIBLE HEADER 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,46 @@ 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);
}
// Redirect to login, but only once
else if (request->path.compare("/login") != 0) {
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, ';')) {
// 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());
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;
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 +199,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;
@@ -655,6 +683,8 @@ namespace confighttp {
else {
http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword);
http::reload_user_creds(config::sunshine.credentials_file);
// Regen the JWT Key to invalidate sessions
jwt_key = crypto::rand_alphabet(64);
outputTree.put("status", true);
}
}
@@ -738,16 +768,112 @@ 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("JWT").set_issued_at(std::chrono::system_clock::now()).set_expires_at(std::chrono::system_clock::now() + 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
logout(resp_https_t response, req_https_t request) {
pt::ptree outputTree;
try {
if (!authenticate(response, request)) return;
print_req(request);
auto g = util::fail_guard([&]() {
std::ostringstream data;
pt::write_json(data, outputTree);
response->write(data.str());
});
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Set-Cookie", "sunshine_session=redacted; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Strict; Path=/" }
};
std::ostringstream data;
outputTree.put("status", true);
pt::write_json(data, outputTree);
response->write(SimpleWeb::StatusCode::success_ok, data.str(), headers);
g.disable();
}
catch (std::exception &e) {
BOOST_LOG(warning) << "SaveApp: "sv << e.what();
outputTree.put("status", "false");
outputTree.put("error", "Invalid Input JSON");
return;
}
}
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;
@@ -768,6 +894,8 @@ 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/logout$"]["POST"] = logout;
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;

View File

@@ -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>

View File

@@ -29,13 +29,39 @@
<a class="nav-link" href="/troubleshooting"><i class="fas fa-fw fa-info"></i> {{ $t('navbar.troubleshoot') }}</a>
</li>
</ul>
<ul class="navbar-nav mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="#" @click="logout"><i class="fas fa-fw fa-right-from-bracket"></i> Logout</a>
</li>
</ul>
</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 +71,20 @@ 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();
},
logout(){
fetch("/api/logout",{method: "POST"}).then(r => {
document.location.href = '/';
})
}
}
}
</script>

View File

@@ -359,6 +359,7 @@
import i18n from './locale.js'
import Navbar from './Navbar.vue'
import {Dropdown} from 'bootstrap'
import fetch from './fetch.js'
const app = createApp({
components: {

View File

@@ -1072,6 +1072,7 @@
import { createApp } from 'vue'
import i18n from './locale.js'
import Navbar from './Navbar.vue'
import fetch from './fetch.js'
const app = createApp({
components: {

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

View File

@@ -76,6 +76,7 @@
import i18n from './locale.js'
import Navbar from './Navbar.vue'
import ResourceCard from './ResourceCard.vue'
import fetch from './fetch.js'
console.log("Hello, Sunshine!")
let app = createApp({

View File

@@ -0,0 +1,44 @@
<!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="p-4 card">
<header>
<h1 class="mb-2">
<img src="/images/logo-sunshine-45.png" height="45" alt="" style="vertical-align: bottom;">
Hello, Sunshine!
</h1>
</header>
<Login-Form @loggedin="onLogin"></Login-Form>
</div>
<div>
<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
},
methods: {
onLogin() {
document.location.href = '/';
}
}
});
console.log("App", app);
app.mount("#app");
</script>

View File

@@ -69,6 +69,7 @@
import { createApp } from 'vue'
import i18n from './locale.js'
import Navbar from './Navbar.vue'
import fetch from './fetch.js'
const app = createApp({
components: {

View File

@@ -26,6 +26,7 @@
import { createApp } from 'vue'
import i18n from './locale.js'
import Navbar from './Navbar.vue'
import fetch from './fetch.js'
let app = createApp({
components: {

View File

@@ -116,6 +116,7 @@
import { createApp } from 'vue'
import i18n from './locale.js'
import Navbar from './Navbar.vue'
import fetch from './fetch.js'
const app = createApp({
components: {

View File

@@ -55,6 +55,8 @@
import { createApp } from "vue"
import i18n from './locale.js'
import ResourceCard from './ResourceCard.vue'
import fetch from './fetch.js'
let app = createApp({
components: {

1
third-party/jwt-cpp vendored Submodule

Submodule third-party/jwt-cpp added at 08bcf77a68

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'),