mirror of
https://github.com/LizardByte/Sunshine.git
synced 2025-08-10 00:52:16 +00:00
Compare commits
6 Commits
dependabot
...
feat/new-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3888ec8da0 | ||
|
|
8ba64ffa32 | ||
|
|
968b7963ee | ||
|
|
61df838356 | ||
|
|
d1845df0ea | ||
|
|
9ef63ca829 |
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
61
src_assets/common/assets/web/LoginForm.vue
Normal file
61
src_assets/common/assets/web/LoginForm.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
9
src_assets/common/assets/web/fetch.js
Normal file
9
src_assets/common/assets/web/fetch.js
Normal 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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
44
src_assets/common/assets/web/login.html
Normal file
44
src_assets/common/assets/web/login.html
Normal 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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
1
third-party/jwt-cpp
vendored
Submodule
Submodule third-party/jwt-cpp added at 08bcf77a68
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user