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

Replace Basic Authentication with JWT Tokens, Added Login Page #2252

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitmodules
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmake/compile_definitions/common.cmake
Expand Up @@ -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}
)
Expand Down
162 changes: 145 additions & 17 deletions src/confighttp.cpp
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we are already refactoring stuff here, can we move out all authentication logic/implementation to another file like http_authenticator or something, and only call it here?

Would also make it easier to unit test, fix, replace or even support multiple auth styles in the future.

Expand Up @@ -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"
Expand All @@ -47,6 +48,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 @@ -64,7 +67,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 @@ -80,9 +83,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 @@ -114,29 +115,46 @@
}

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

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 146 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L146

Added line #L146 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 @@ -181,6 +199,16 @@
response->write(content, headers);
}

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

Check warning on line 204 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L203-L204

Added lines #L203 - L204 were not covered by tests

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

Check warning on line 206 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L206

Added line #L206 was not covered by tests
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
}

Check warning on line 210 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L210

Added line #L210 was not covered by tests

void
getAppsPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
Expand Down Expand Up @@ -655,6 +683,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 @@ -738,16 +768,112 @@
outputTree.put("status", true);
}

void
login(resp_https_t response, req_https_t request) {

Check warning on line 772 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L772

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

Check warning on line 779 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L779

Added line #L779 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 787 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L787

Added line #L787 was not covered by tests

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

Check warning on line 791 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L791

Added line #L791 was not covered by tests

try {

Check warning on line 793 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L793

Added line #L793 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 802 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L802

Added line #L802 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 818 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L817-L818

Added lines #L817 - L818 were 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 825 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L824-L825

Added lines #L824 - L825 were 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 833 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L831-L833

Added lines #L831 - L833 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 839 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L839

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

Check warning on line 842 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L842

Added line #L842 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=/" }
ReenigneArcher marked this conversation as resolved.
Show resolved Hide resolved
};
std::ostringstream data;
outputTree.put("status", true);
pt::write_json(data, outputTree);

response->write(SimpleWeb::StatusCode::success_ok, data.str(), headers);
g.disable();
}

Check warning on line 853 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L853

Added line #L853 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 860 in src/confighttp.cpp

View check run for this annotation

Codecov / codecov/patch

src/confighttp.cpp#L859-L860

Added lines #L859 - L860 were 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 @@ -768,6 +894,8 @@
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;
Expand Down
61 changes: 61 additions & 0 deletions src_assets/common/assets/web/LoginForm.vue
@@ -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>
40 changes: 40 additions & 0 deletions src_assets/common/assets/web/Navbar.vue
Expand Up @@ -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!")
},
Expand All @@ -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>
Expand Down
1 change: 1 addition & 0 deletions src_assets/common/assets/web/apps.html
Expand Up @@ -359,6 +359,7 @@ <h4>{{ $t('apps.env_vars_about') }}</h4>
import i18n from './locale.js'
import Navbar from './Navbar.vue'
import {Dropdown} from 'bootstrap'
import fetch from './fetch.js'

const app = createApp({
components: {
Expand Down