Change login from http basic auth to cookies

This commit is contained in:
Yukino Song
2024-08-30 06:17:02 +08:00
parent c0e65632f6
commit 652661ea50
11 changed files with 289 additions and 109 deletions

View File

@@ -53,6 +53,8 @@ namespace confighttp {
using resp_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Response>;
using req_https_t = std::shared_ptr<typename SimpleWeb::ServerBase<SimpleWeb::HTTPS>::Request>;
std::string sessionCookie;
enum class op_e {
ADD, ///< Add client
REMOVE ///< Remove client
@@ -80,10 +82,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")" }
};
response->write(SimpleWeb::StatusCode::client_error_unauthorized, headers);
response->write(SimpleWeb::StatusCode::client_error_unauthorized);
}
void
@@ -96,8 +95,26 @@ namespace confighttp {
response->write(SimpleWeb::StatusCode::redirection_temporary_redirect, headers);
}
std::string getCookieValue(const std::string& cookieString, const std::string& key) {
std::string keyWithEqual = key + "=";
std::size_t startPos = cookieString.find(keyWithEqual);
if (startPos == std::string::npos) {
return "";
}
startPos += keyWithEqual.length();
std::size_t endPos = cookieString.find(";", startPos);
if (endPos == std::string::npos) {
return cookieString.substr(startPos);
}
return cookieString.substr(startPos, endPos - startPos);
}
bool
authenticate(resp_https_t response, req_https_t request) {
checkIPOrigin(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);
@@ -107,6 +124,15 @@ namespace confighttp {
return false;
}
return true;
}
bool
authenticate(resp_https_t response, req_https_t request, bool needsRedirect = false) {
if (!checkIPOrigin(response, request)) {
return false;
}
// If credentials are shown, redirect the user to a /welcome page
if (config::sunshine.username.empty()) {
send_redirect(response, request, "/welcome");
@@ -114,27 +140,24 @@ namespace confighttp {
}
auto fg = util::fail_guard([&]() {
send_unauthorized(response, request);
if (needsRedirect) {
send_redirect(response, request, "/login");
} else {
send_unauthorized(response, request);
}
});
auto auth = request->header.find("authorization");
if (auth == request->header.end()) {
if (sessionCookie.empty()) {
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) {
auto cookies = request->header.find("cookie");
if (cookies == request->header.end()) {
return false;
}
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 (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) {
auto authCookie = getCookieValue(cookies->second, "auth");
if (authCookie.empty() || authCookie != sessionCookie) {
return false;
}
@@ -156,105 +179,73 @@ namespace confighttp {
<< data.str();
}
/**
* @todo combine these functions into a single function that accepts the page, i.e "index", "pin", "apps"
*/
void
getIndexPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
fetchStaticPage(resp_https_t response, req_https_t request, const std::string& page, bool needsAuthenticate) {
if (needsAuthenticate) {
if (!authenticate(response, request, true)) return;
}
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "index.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
std::string content = file_handler::read_file((WEB_DIR + page).c_str());
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Content-Type", "text/html; charset=utf-8" },
{ "Access-Control-Allow-Origin", "https://images.igdb.com/"}
};
response->write(content, headers);
};
void
getIndexPage(resp_https_t response, req_https_t request) {
fetchStaticPage(response, request, "index.html", true);
}
void
getPinPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "pin.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
fetchStaticPage(response, request, "pin.html", true);
}
void
getAppsPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "apps.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
headers.emplace("Access-Control-Allow-Origin", "https://images.igdb.com/");
response->write(content, headers);
}
void
getClientsPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "clients.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
fetchStaticPage(response, request, "apps.html", true);
}
void
getConfigPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "config.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
fetchStaticPage(response, request, "config.html", true);
}
void
getPasswordPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "password.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
fetchStaticPage(response, request, "password.html", true);
}
void
getWelcomePage(resp_https_t response, req_https_t request) {
print_req(request);
if (!checkIPOrigin(response, request)) {
return;
}
if (!config::sunshine.username.empty()) {
send_redirect(response, request, "/");
return;
}
std::string content = file_handler::read_file(WEB_DIR "welcome.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
fetchStaticPage(response, request, "welcome.html", false);
}
void
getLoginPage(resp_https_t response, req_https_t request) {
if (!checkIPOrigin(response, request)) {
return;
}
fetchStaticPage(response, request, "login.html", false);
}
void
getTroubleshootingPage(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
print_req(request);
std::string content = file_handler::read_file(WEB_DIR "troubleshooting.html");
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/html; charset=utf-8");
response->write(content, headers);
fetchStaticPage(response, request, "troubleshooting.html", true);
}
/**
@@ -263,11 +254,16 @@ namespace confighttp {
*/
void
getFaviconImage(resp_https_t response, req_https_t request) {
if (!checkIPOrigin(response, request)) {
return;
}
print_req(request);
std::ifstream in(WEB_DIR "images/apollo.ico", std::ios::binary);
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "image/x-icon");
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Content-Type", "image/x-icon" }
};
response->write(SimpleWeb::StatusCode::success_ok, in, headers);
}
@@ -277,11 +273,16 @@ namespace confighttp {
*/
void
getSunshineLogoImage(resp_https_t response, req_https_t request) {
if (!checkIPOrigin(response, request)) {
return;
}
print_req(request);
std::ifstream in(WEB_DIR "images/logo-apollo-45.png", std::ios::binary);
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "image/png");
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Content-Type", "image/png" }
};
response->write(SimpleWeb::StatusCode::success_ok, in, headers);
}
@@ -293,6 +294,10 @@ namespace confighttp {
void
getNodeModules(resp_https_t response, req_https_t request) {
if (!checkIPOrigin(response, request)) {
return;
}
print_req(request);
fs::path webDirPath(WEB_DIR);
fs::path nodeModulesPath(webDirPath / "assets");
@@ -332,8 +337,9 @@ namespace confighttp {
print_req(request);
std::string content = file_handler::read_file(config::stream.file_apps.c_str());
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "application/json");
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Content-Type", "application/json" }
};
response->write(content, headers);
}
@@ -344,8 +350,9 @@ namespace confighttp {
print_req(request);
std::string content = file_handler::read_file(config::sunshine.log_file.c_str());
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Content-Type", "text/plain");
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Content-Type", "text/plain" }
};
response->write(SimpleWeb::StatusCode::success_ok, content, headers);
}
@@ -691,6 +698,10 @@ namespace confighttp {
else {
http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword);
http::reload_user_creds(config::sunshine.credentials_file);
// Force user to re-login
sessionCookie.clear();
outputTree.put("status", true);
}
}
@@ -708,6 +719,48 @@ namespace confighttp {
}
}
void
login(resp_https_t response, req_https_t request) {
if (!checkIPOrigin(response, request)) {
return;
}
auto fg = util::fail_guard([&]{
response->write(SimpleWeb::StatusCode::client_error_unauthorized);
});
std::stringstream ss;
ss << request->content.rdbuf();
pt::ptree inputTree;
try {
pt::read_json(ss, inputTree);
std::string username = inputTree.get<std::string>("username");
std::string password = inputTree.get<std::string>("password");
std::string hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string();
if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) {
return;
}
sessionCookie = crypto::rand_alphabet(64);
const SimpleWeb::CaseInsensitiveMultimap headers {
{ "Set-Cookie", "auth=" + sessionCookie + "; Secure; Max-Age=2592000; Path=/" }
};
response->write(headers);
fg.disable();
} catch (std::exception &e) {
BOOST_LOG(warning) << "Web UI Login failed: ["sv << net::addr_to_normalized_string(request->remote_endpoint().address()) << "]: "sv << e.what();
response->write(SimpleWeb::StatusCode::server_error_internal_server_error);
fg.disable();
return;
}
}
void
savePin(resp_https_t response, req_https_t request) {
if (!authenticate(response, request)) return;
@@ -886,11 +939,12 @@ namespace confighttp {
server.resource["^/$"]["GET"] = getIndexPage;
server.resource["^/pin/?$"]["GET"] = getPinPage;
server.resource["^/apps/?$"]["GET"] = getAppsPage;
server.resource["^/clients/?$"]["GET"] = getClientsPage;
server.resource["^/config/?$"]["GET"] = getConfigPage;
server.resource["^/password/?$"]["GET"] = getPasswordPage;
server.resource["^/welcome/?$"]["GET"] = getWelcomePage;
server.resource["^/login/?$"]["GET"] = getLoginPage;
server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage;
server.resource["^/api/login"]["POST"] = login;
server.resource["^/api/pin$"]["POST"] = savePin;
server.resource["^/api/otp$"]["GET"] = getOTP;
server.resource["^/api/apps$"]["GET"] = getApps;

View File

@@ -407,14 +407,18 @@
};
},
created() {
fetch("/api/apps")
fetch("/api/apps", {
credentials: 'include'
})
.then((r) => r.json())
.then((r) => {
console.log(r);
this.apps = r.apps;
});
fetch("/api/config")
fetch("/api/config", {
credentials: 'include'
})
.then(r => r.json())
.then(r => this.platform = r.platform);
},
@@ -473,7 +477,10 @@
"Are you sure to delete " + this.apps[id].name + "?"
);
if (resp) {
fetch("/api/apps/" + id, { method: "DELETE" }).then((r) => {
fetch("/api/apps/" + id, {
credentials: 'include',
method: "DELETE"
}).then((r) => {
if (r.status == 200) document.location.reload();
});
}
@@ -569,6 +576,7 @@
useCover(cover) {
this.coverFinderBusy = true;
fetch("/api/covers/upload", {
credentials: 'include',
method: "POST",
body: JSON.stringify({
key: cover.key,
@@ -584,6 +592,7 @@
save() {
this.editForm["image-path"] = this.editForm["image-path"].toString().replace(/"/g, '');
fetch("/api/apps", {
credentials: 'include',
method: "POST",
body: JSON.stringify(this.editForm),
}).then((r) => {

View File

@@ -283,7 +283,9 @@
}
},
created() {
fetch("/api/config")
fetch("/api/config", {
credentials: 'include'
})
.then((r) => r.json())
.then((r) => {
this.config = r;
@@ -372,6 +374,7 @@
});
return fetch("/api/config", {
credentials: 'include',
method: "POST",
body: JSON.stringify(config),
}).then((r) => {

View File

@@ -4,7 +4,7 @@ import {createI18n} from "vue-i18n";
import en from './public/assets/locale/en.json'
export default async function() {
let r = await (await fetch("/api/configLocale")).json();
let r = await (await fetch("/api/configLocale", { credentials: 'include' })).json();
let locale = r.locale ?? "en";
document.querySelector('html').setAttribute('lang', locale);
let messages = {
@@ -12,7 +12,7 @@ export default async function() {
};
try {
if (locale !== 'en') {
let r = await (await fetch(`/assets/locale/${locale}.json`)).json();
let r = await (await fetch(`/assets/locale/${locale}.json`, { credentials: 'include' })).json();
messages[locale] = r;
}
} catch (e) {

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<%- header %>
</head>
<body id="app" v-cloak>
<main role="main" style="max-width: 1200px; margin: 1em auto">
<div class="d-flex justify-content-center">
<div class="card p-2">
<header>
<h1 class="mb-0">
<img src="/images/logo-apollo-45.png" height="45" alt="">
{{ $t('welcome.greeting') }}
</h1>
</header>
<form @submit.prevent="save" class="mt-4">
<div class="mb-2">
<label for="usernameInput" class="form-label">{{ $t('_common.username') }}</label>
<input type="text" class="form-control" id="usernameInput" autocomplete="username"
v-model="passwordData.username" />
</div>
<div class="mb-4">
<label for="passwordInput" class="form-label">{{ $t('_common.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">
{{ $t('welcome.login') }}
</button>
<div class="alert alert-danger" v-if="error"><b>{{ $t('_common.error') }}</b> {{error}}</div>
<div class="alert alert-success" v-if="success">
<b>{{ $t('_common.success') }}</b> {{ $t('welcome.login_success') }}
</div>
</form>
</div>
</div>
</div>
</main>
</body>
<script type="module">
import { createApp } from "vue"
import { initApp } from './init'
let app = createApp({
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((res) => {
this.loading = false;
if (res.status === 200) {
this.success = true;
location.href = './';
} else {
throw new Error(`Server returned ${res.status}`);
}
}).catch((e) => {
this.error = `Login failed: ${e.message}`;
});
},
},
});
initApp(app);
</script>

View File

@@ -95,6 +95,7 @@
};
this.error = null;
fetch("/api/password", {
credentials: 'include',
method: "POST",
body: JSON.stringify(this.passwordData),
}).then((r) => {

View File

@@ -78,7 +78,11 @@
let name = document.querySelector("#name-input").value;
document.querySelector("#status").innerHTML = "";
let b = JSON.stringify({pin: pin, name: name});
fetch("/api/pin", {method: "POST", body: b})
fetch("/api/pin", {
credentials: 'include',
method: "POST",
body: b
})
.then((response) => response.json())
.then((response) => {
if (response.status.toString().toLowerCase() === "true") {
@@ -95,7 +99,9 @@
});
},
requestOTP() {
fetch(`/api/otp?passphrase=${this.passphrase}${this.deviceName && `&deviceName=${this.deviceName}` || ''}`)
fetch(`/api/otp?passphrase=${this.passphrase}${this.deviceName && `&deviceName=${this.deviceName}` || ''}`, {
credentials: 'include'
})
.then(resp => resp.json())
.then(resp => {
if (resp.status !== 'true') {

View File

@@ -423,6 +423,7 @@
"create_creds_alert": "The credentials below are needed to access Apollo's Web UI. Keep them safe, since you will never see them again!",
"greeting": "Welcome to Apollo!",
"login": "Login",
"welcome_success": "This page will reload soon, your browser will ask you for the new credentials"
"welcome_success": "This page will reload soon, your browser will ask you for the new credentials",
"login_success": "This page will reload soon."
}
}

View File

@@ -424,6 +424,7 @@
"create_creds_alert": "需要下面的账户信息才能访问 Apollo 的 Web UI 。请妥善保存,因为你再也不会见到它们!",
"greeting": "欢迎使用 Apollo",
"login": "登录",
"welcome_success": "此页面将很快重新加载,您的浏览器将询问您新的账户信息"
"welcome_success": "此页面将很快重新加载,您的浏览器将询问您新的账户信息",
"login_success": "此页面将重新加载。"
}
}

View File

@@ -163,7 +163,7 @@
logs: 'Loading...',
logFilter: null,
logInterval: null,
serterRestarting: false,
serverRestarting: false,
serverQuitting: false,
serverQuit: false,
showApplyMessage: false,
@@ -191,7 +191,7 @@
},
methods: {
refreshLogs() {
fetch("/api/logs",)
fetch("/api/logs", { credentials: 'include' })
.then((r) => r.text())
.then((r) => {
this.logs = r;
@@ -199,7 +199,10 @@
},
closeApp() {
this.closeAppPressed = true;
fetch("/api/apps/close", { method: "POST" })
fetch("/api/apps/close", {
credentials: 'include',
method: "POST"
})
.then((r) => r.json())
.then((r) => {
this.closeAppPressed = false;
@@ -211,7 +214,10 @@
},
unpairAll() {
this.unpairAllPressed = true;
fetch("/api/clients/unpair-all", { method: "POST" })
fetch("/api/clients/unpair-all", {
credentials: 'include',
method: "POST"
})
.then((r) => r.json())
.then((r) => {
this.unpairAllPressed = false;
@@ -223,13 +229,16 @@
});
},
unpairSingle(uuid) {
fetch("/api/clients/unpair", { method: "POST", body: JSON.stringify({ uuid }) }).then(() => {
fetch("/api/clients/unpair", { credentials: 'include',
method: "POST",
body: JSON.stringify({ uuid })
}).then(() => {
this.showApplyMessage = true;
this.refreshClients();
});
},
refreshClients() {
fetch("/api/clients/list")
fetch("/api/clients/list", { credentials: 'include' })
.then((response) => response.json())
.then((response) => {
const clientList = document.querySelector("#client-list");
@@ -254,13 +263,26 @@
this.serverRestarting = false;
}, 5000);
fetch("/api/restart", {
credentials: 'include',
method: "POST",
})
.then((resp) => {
if (resp.status !== 200) {
location.href = './login'
return
}
})
.catch((e) => {
this.serverRestarting = false;
console.error(e);
alert("Restart error!");
});
},
quit() {
if (window.confirm("Do you really want to quit Apollo? You'll not be able to start Apollo again if you have no other methods to operate your computer.")) {
this.serverQuitting = true;
fetch("/api/quit", {
credentials: 'include',
method: "POST",
})
.then(() => {

View File

@@ -53,6 +53,7 @@ export default defineConfig({
pin: resolve(assetsSrcPath, 'pin.html'),
troubleshooting: resolve(assetsSrcPath, 'troubleshooting.html'),
welcome: resolve(assetsSrcPath, 'welcome.html'),
login: resolve(assetsSrcPath, 'login.html')
},
},
},