Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Replace Basic Authentication with JWT Tokens, Added Login Page #2995

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
path = third-party/inputtino
url = https://github.com/games-on-whales/inputtino.git
branch = stable
[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
Expand Down
1 change: 1 addition & 0 deletions cmake/compile_definitions/common.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,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}
${Boost_INCLUDE_DIRS} # has to be the last, or we get runtime error on macOS ffmpeg encoder
)
Expand Down
162 changes: 145 additions & 17 deletions src/confighttp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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"
Expand All @@ -46,6 +47,8 @@
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;
Expand All @@ -63,7 +66,7 @@
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;
Expand All @@ -79,9 +82,7 @@
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);
}

Expand Down Expand Up @@ -113,29 +114,46 @@
}

auto fg = util::fail_guard([&]() {
send_unauthorized(response, request);
BOOST_LOG(info) << request->path;
std::string apiPrefix = "/api";

Check warning on line 118 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L118

Added line #L118 was not covered by tests
if (request->path.compare(0, apiPrefix.length(), apiPrefix) == 0) {
send_unauthorized(response, request);
}
// Redirect to login, but only once
else if (!request->path.starts_with("/login")) {
send_redirect(response, request, ("/login?redirect=" + request->path).c_str());
}
});

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()));

int index = authData.find(':');
if (index >= authData.size() - 1) {
return false;
std::istringstream iss(rawAuth);
std::string token, cookie_name = "sunshine_session=", cookie_value = "";

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;

Check warning on line 145 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L145

Added line #L145 was not covered by tests
}
}

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;
Expand Down Expand Up @@ -182,6 +200,16 @@
response->write(content, headers);
}

void
getLoginPage(resp_https_t response, req_https_t request) {
print_req(request);

Check warning on line 205 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L204-L205

Added lines #L204 - L205 were not covered by tests

std::string content = file_handler::read_file(WEB_DIR "login.html");

Check warning on line 207 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L207

Added line #L207 was not covered by tests
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;
Expand Down Expand Up @@ -658,6 +686,8 @@
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);
}
}
Expand Down Expand Up @@ -796,16 +826,112 @@
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());

Check warning on line 831 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L830-L831

Added lines #L830 - L831 were not covered by tests
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;

Check warning on line 837 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L837

Added line #L837 was not covered by tests
}

std::stringstream ss;
ss << request->content.rdbuf();

pt::ptree inputTree, outputTree;
auto g = util::fail_guard([&]() {
std::ostringstream data;

Check warning on line 845 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L845

Added line #L845 was not covered by tests

pt::write_json(data, outputTree);
response->write(data.str());
});

Check warning on line 849 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L849

Added line #L849 was not covered by tests

try {

Check warning on line 851 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L851

Added line #L851 was not covered by tests
// 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;

Check warning on line 860 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L860

Added line #L860 was not covered by tests
}
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;

Check warning on line 875 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L875

Added line #L875 was not covered by tests
}
catch (std::exception &e) {
BOOST_LOG(warning) << "SaveApp: "sv << e.what();

outputTree.put("status", "false");
outputTree.put("error", "Invalid Input JSON");
return;

Check warning on line 882 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L882

Added line #L882 was not covered by tests
}

outputTree.put("status", "true");
}

void
logout(resp_https_t response, req_https_t request) {
pt::ptree outputTree;
try {

Check warning on line 891 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L889-L891

Added lines #L889 - L891 were not covered by tests
if (!authenticate(response, request)) return;

print_req(request);

auto g = util::fail_guard([&]() {
std::ostringstream data;

Check warning on line 897 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L897

Added line #L897 was not covered by tests
pt::write_json(data, outputTree);
response->write(data.str());
});

Check warning on line 900 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L900

Added line #L900 was not covered by tests

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;

Check warning on line 917 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L917

Added line #L917 was not covered by tests
}
}

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;
Expand All @@ -828,6 +954,8 @@
server.resource["^/api/clients/unpair$"]["POST"] = unpair;
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;
Expand Down
73 changes: 73 additions & 0 deletions src_assets/common/assets/web/LoginForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<template>
<form id="login-form" @submit.prevent="save" method="post" autocomplete="on">
<div class="mb-2">
<label for="usernameInput" class="form-label">Username:</label>
<input type="text" class="form-control" id="usernameInput" autocomplete="username" name="username"
v-model="passwordData.username" autofocus/>
</div>
<div class="mb-2">
<label for="passwordInput" class="form-label">Password:</label>
<input type="password" class="form-control" id="passwordInput" autocomplete="current-password" name="current-password"
v-model="passwordData.password" required />
</div>
<input type="submit" class="btn btn-primary w-100 mb-2" v-bind:disabled="loading" value="Login"/>
<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;
const emitter = this.$emit;

if (window.PasswordCredential) {
const c = new PasswordCredential(
document.getElementById("login-form")
);

navigator.credentials.store(c).then((a) => {
emitter('loggedin');
}).catch((err) => {
emitter('loggedin');
});
} else {
emitter('loggedin');
}
} else {
this.error = rj.error || "Invalid Username or Password";
}
});
} else {
this.error = "Internal Server Error";
}
});
},
},
}
</script>
Loading
Loading