mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-04-28 09:17:17 +00:00
feat: forbid form data in html
It has been a while since all pages have moved to json instead of form data, except for login page. This commit changes login to json data instead of form one
This commit is contained in:
parent
82d1be52a8
commit
d72677aa29
@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const alertPlaceholder = $("#alert-placeholder");
|
const alertPlaceholder = $("#alert-placeholder");
|
||||||
|
|
||||||
function createAlert(title, message, clz) {
|
function createAlert(title, message, clz, action) {
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.classList.add("toast", clz);
|
wrapper.classList.add("toast", clz);
|
||||||
wrapper.role = "alert";
|
wrapper.role = "alert";
|
||||||
@ -23,7 +23,7 @@
|
|||||||
const toast = new bootstrap.Toast(wrapper);
|
const toast = new bootstrap.Toast(wrapper);
|
||||||
wrapper.addEventListener("hidden.bs.toast", () => {
|
wrapper.addEventListener("hidden.bs.toast", () => {
|
||||||
wrapper.remove(); // bootstrap doesn't remove elements
|
wrapper.remove(); // bootstrap doesn't remove elements
|
||||||
reload();
|
(action || reload)();
|
||||||
});
|
});
|
||||||
toast.show();
|
toast.show();
|
||||||
}
|
}
|
||||||
@ -38,8 +38,8 @@
|
|||||||
createAlert(title, description(details), "text-bg-danger");
|
createAlert(title, description(details), "text-bg-danger");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSuccess(title, description) {
|
function showSuccess(title, description, action) {
|
||||||
createAlert(title, description, "text-bg-success");
|
createAlert(title, description, "text-bg-success", action);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<div id="login-modal" tabindex="-1" role="dialog" class="modal fade">
|
<div id="login-modal" tabindex="-1" role="dialog" class="modal fade">
|
||||||
<div class="modal-dialog" role="document">
|
<div class="modal-dialog" role="document">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<form action="/api/v1/login" method="post">
|
<form id="login-form" onsubmit="return false">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title">Login</h4>
|
<h4 class="modal-title">Login</h4>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
||||||
@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-primary"><i class="bi bi-person"></i> login</button>
|
<button type="submit" class="btn btn-primary" onclick="login()"><i class="bi bi-person"></i> login</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -34,16 +34,45 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const passwordInput = $("#login-password");
|
const loginModal = $("#login-modal");
|
||||||
|
const loginForm = $("#login-form");
|
||||||
|
loginModal.on("hidden.bs.modal", () => {
|
||||||
|
loginForm.trigger("reset");
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginPasswordInput = $("#login-password");
|
||||||
|
const loginUsernameInput = $("#login-username");
|
||||||
const showHidePasswordButton = $("#login-show-hide-password-button");
|
const showHidePasswordButton = $("#login-show-hide-password-button");
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
const password = loginPasswordInput.val();
|
||||||
|
const username = loginUsernameInput.val();
|
||||||
|
|
||||||
|
if (username && password) {
|
||||||
|
$.ajax({
|
||||||
|
url: "/api/v1/login",
|
||||||
|
data: JSON.stringify({username: username, password: password}),
|
||||||
|
type: "POST",
|
||||||
|
contentType: "application/json",
|
||||||
|
success: _ => {
|
||||||
|
loginModal.modal("hide");
|
||||||
|
showSuccess("Logged in", `Successfully logged in as ${username}`, () => location.href = "/");
|
||||||
|
},
|
||||||
|
error: (jqXHR, _, errorThrown) => {
|
||||||
|
const message = _ => `Could not login as ${username}`;
|
||||||
|
showFailure("Login error", message, jqXHR, errorThrown);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showPassword() {
|
function showPassword() {
|
||||||
if (passwordInput.attr("type") === "password") {
|
if (loginPasswordInput.attr("type") === "password") {
|
||||||
passwordInput.attr("type", "text");
|
loginPasswordInput.attr("type", "text");
|
||||||
showHidePasswordButton.removeClass("bi-eye");
|
showHidePasswordButton.removeClass("bi-eye");
|
||||||
showHidePasswordButton.addClass("bi-eye-slash");
|
showHidePasswordButton.addClass("bi-eye-slash");
|
||||||
} else {
|
} else {
|
||||||
passwordInput.attr("type", "password");
|
loginPasswordInput.attr("type", "password");
|
||||||
showHidePasswordButton.removeClass("bi-eye-slash");
|
showHidePasswordButton.removeClass("bi-eye-slash");
|
||||||
showHidePasswordButton.addClass("bi-eye");
|
showHidePasswordButton.addClass("bi-eye");
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
from aiohttp_cors import CorsViewMixin # type: ignore[import-untyped]
|
from aiohttp_cors import CorsViewMixin # type: ignore[import-untyped]
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View
|
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from ahriman.core.auth import Auth
|
from ahriman.core.auth import Auth
|
||||||
from ahriman.core.configuration import Configuration
|
from ahriman.core.configuration import Configuration
|
||||||
@ -138,47 +138,6 @@ class BaseView(View, CorsViewMixin):
|
|||||||
raise KeyError(f"Key {key} is missing or empty") from None
|
raise KeyError(f"Key {key} is missing or empty") from None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
async def data_as_json(self, list_keys: list[str]) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
extract form data and convert it to json object
|
|
||||||
|
|
||||||
Args:
|
|
||||||
list_keys(list[str]): list of keys which must be forced to list from form data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: form data converted to json. In case if a key is found multiple times
|
|
||||||
it will be returned as list
|
|
||||||
"""
|
|
||||||
raw = await self.request.post()
|
|
||||||
json: dict[str, Any] = {}
|
|
||||||
for key, value in raw.items():
|
|
||||||
if key in json and isinstance(json[key], list):
|
|
||||||
json[key].append(value)
|
|
||||||
elif key in json:
|
|
||||||
json[key] = [json[key], value]
|
|
||||||
elif key in list_keys:
|
|
||||||
json[key] = [value]
|
|
||||||
else:
|
|
||||||
json[key] = value
|
|
||||||
return json
|
|
||||||
|
|
||||||
async def extract_data(self, list_keys: list[str] | None = None) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
extract json data from either json or form data
|
|
||||||
|
|
||||||
Args:
|
|
||||||
list_keys(list[str] | None, optional): optional list of keys which must be forced to list from form data
|
|
||||||
(Default value = None)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: raw json object or form data converted to json
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
json: dict[str, Any] = await self.request.json()
|
|
||||||
return json
|
|
||||||
except ValueError:
|
|
||||||
return await self.data_as_json(list_keys or [])
|
|
||||||
|
|
||||||
# pylint: disable=not-callable,protected-access
|
# pylint: disable=not-callable,protected-access
|
||||||
async def head(self) -> StreamResponse: # type: ignore[return]
|
async def head(self) -> StreamResponse: # type: ignore[return]
|
||||||
"""
|
"""
|
||||||
|
@ -66,7 +66,7 @@ class AddView(BaseView):
|
|||||||
HTTPBadRequest: if bad data is supplied
|
HTTPBadRequest: if bad data is supplied
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await self.extract_data(["packages", "patches"])
|
data = await self.request.json()
|
||||||
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
||||||
patches = [PkgbuildPatch(patch["key"], patch.get("value", "")) for patch in data.get("patches", [])]
|
patches = [PkgbuildPatch(patch["key"], patch.get("value", "")) for patch in data.get("patches", [])]
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
@ -104,9 +104,8 @@ class PGPView(BaseView):
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPBadRequest: if bad data is supplied
|
HTTPBadRequest: if bad data is supplied
|
||||||
"""
|
"""
|
||||||
data = await self.extract_data()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
data = await self.request.json()
|
||||||
key = self.get_non_empty(data.get, "key")
|
key = self.get_non_empty(data.get, "key")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise HTTPBadRequest(reason=str(ex))
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
@ -65,7 +65,7 @@ class RebuildView(BaseView):
|
|||||||
HTTPBadRequest: if bad data is supplied
|
HTTPBadRequest: if bad data is supplied
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await self.extract_data(["packages"])
|
data = await self.request.json()
|
||||||
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
||||||
depends_on = next(iter(packages))
|
depends_on = next(iter(packages))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
@ -65,7 +65,7 @@ class RemoveView(BaseView):
|
|||||||
HTTPBadRequest: if bad data is supplied
|
HTTPBadRequest: if bad data is supplied
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await self.extract_data(["packages"])
|
data = await self.request.json()
|
||||||
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise HTTPBadRequest(reason=str(ex))
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
@ -66,7 +66,7 @@ class RequestView(BaseView):
|
|||||||
HTTPBadRequest: if bad data is supplied
|
HTTPBadRequest: if bad data is supplied
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await self.extract_data(["packages", "patches"])
|
data = await self.request.json()
|
||||||
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
packages = self.get_non_empty(lambda key: [package for package in data[key] if package], "packages")
|
||||||
patches = [PkgbuildPatch(patch["key"], patch.get("value", "")) for patch in data.get("patches", [])]
|
patches = [PkgbuildPatch(patch["key"], patch.get("value", "")) for patch in data.get("patches", [])]
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
@ -65,7 +65,7 @@ class UpdateView(BaseView):
|
|||||||
HTTPBadRequest: if bad data is supplied
|
HTTPBadRequest: if bad data is supplied
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await self.extract_data()
|
data = await self.request.json()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise HTTPBadRequest(reason=str(ex))
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
|
||||||
|
@ -139,9 +139,9 @@ class LogsView(BaseView):
|
|||||||
HTTPNoContent: in case of success response
|
HTTPNoContent: in case of success response
|
||||||
"""
|
"""
|
||||||
package_base = self.request.match_info["package"]
|
package_base = self.request.match_info["package"]
|
||||||
data = await self.extract_data()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
data = await self.request.json()
|
||||||
created = data["created"]
|
created = data["created"]
|
||||||
record = data["message"]
|
record = data["message"]
|
||||||
version = data["version"]
|
version = data["version"]
|
||||||
|
@ -142,9 +142,9 @@ class PackageView(BaseView):
|
|||||||
HTTPNoContent: in case of success response
|
HTTPNoContent: in case of success response
|
||||||
"""
|
"""
|
||||||
package_base = self.request.match_info["package"]
|
package_base = self.request.match_info["package"]
|
||||||
data = await self.extract_data()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
data = await self.request.json()
|
||||||
package = Package.from_json(data["package"]) if "package" in data else None
|
package = Package.from_json(data["package"]) if "package" in data else None
|
||||||
status = BuildStatusEnum(data["status"])
|
status = BuildStatusEnum(data["status"])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
@ -95,9 +95,9 @@ class PatchesView(BaseView):
|
|||||||
HTTPNoContent: on success response
|
HTTPNoContent: on success response
|
||||||
"""
|
"""
|
||||||
package_base = self.request.match_info["package"]
|
package_base = self.request.match_info["package"]
|
||||||
data = await self.extract_data()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
data = await self.request.json()
|
||||||
key = data["key"]
|
key = data["key"]
|
||||||
value = data["value"]
|
value = data["value"]
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
@ -102,7 +102,7 @@ class StatusView(BaseView):
|
|||||||
HTTPNoContent: in case of success response
|
HTTPNoContent: in case of success response
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = await self.extract_data()
|
data = await self.request.json()
|
||||||
status = BuildStatusEnum(data["status"])
|
status = BuildStatusEnum(data["status"])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise HTTPBadRequest(reason=str(ex))
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
#
|
#
|
||||||
import aiohttp_apispec # type: ignore[import-untyped]
|
import aiohttp_apispec # type: ignore[import-untyped]
|
||||||
|
|
||||||
from aiohttp.web import HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized
|
from aiohttp.web import HTTPBadRequest, HTTPFound, HTTPMethodNotAllowed, HTTPUnauthorized
|
||||||
|
|
||||||
from ahriman.core.auth.helpers import remember
|
from ahriman.core.auth.helpers import remember
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
@ -93,6 +93,7 @@ class LoginView(BaseView):
|
|||||||
description="Login by using username and password",
|
description="Login by using username and password",
|
||||||
responses={
|
responses={
|
||||||
302: {"description": "Success response"},
|
302: {"description": "Success response"},
|
||||||
|
400: {"description": "Bad data is supplied", "schema": ErrorSchema},
|
||||||
401: {"description": "Authorization required", "schema": ErrorSchema},
|
401: {"description": "Authorization required", "schema": ErrorSchema},
|
||||||
500: {"description": "Internal server error", "schema": ErrorSchema},
|
500: {"description": "Internal server error", "schema": ErrorSchema},
|
||||||
},
|
},
|
||||||
@ -107,11 +108,15 @@ class LoginView(BaseView):
|
|||||||
HTTPFound: on success response
|
HTTPFound: on success response
|
||||||
HTTPUnauthorized: if case of authorization error
|
HTTPUnauthorized: if case of authorization error
|
||||||
"""
|
"""
|
||||||
data = await self.extract_data()
|
try:
|
||||||
identity = data.get("username")
|
data = await self.request.json()
|
||||||
|
identity = data["username"]
|
||||||
|
password = data["password"]
|
||||||
|
except Exception as ex:
|
||||||
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
|
||||||
response = HTTPFound("/")
|
response = HTTPFound("/")
|
||||||
if identity is not None and await self.validator.check_credentials(identity, data.get("password")):
|
if await self.validator.check_credentials(identity, password):
|
||||||
await remember(self.request, response, identity)
|
await remember(self.request, response, identity)
|
||||||
raise response
|
raise response
|
||||||
|
|
||||||
|
@ -87,68 +87,6 @@ def test_get_non_empty() -> None:
|
|||||||
BaseView.get_non_empty(lambda k: [], "key")
|
BaseView.get_non_empty(lambda k: [], "key")
|
||||||
|
|
||||||
|
|
||||||
async def test_data_as_json(base: BaseView) -> None:
|
|
||||||
"""
|
|
||||||
must parse multi value form payload
|
|
||||||
"""
|
|
||||||
json = {"key1": "value1", "key2": ["value2", "value3"], "key3": ["value4", "value5", "value6"]}
|
|
||||||
|
|
||||||
async def get_data():
|
|
||||||
result = MultiDict()
|
|
||||||
for key, values in json.items():
|
|
||||||
if isinstance(values, list):
|
|
||||||
for value in values:
|
|
||||||
result.add(key, value)
|
|
||||||
else:
|
|
||||||
result.add(key, values)
|
|
||||||
return result
|
|
||||||
|
|
||||||
base._request = pytest.helpers.request(base.request.app, "", "", data=get_data)
|
|
||||||
assert await base.data_as_json([]) == json
|
|
||||||
|
|
||||||
|
|
||||||
async def test_data_as_json_with_list_keys(base: BaseView) -> None:
|
|
||||||
"""
|
|
||||||
must parse multi value form payload with forced list
|
|
||||||
"""
|
|
||||||
json = {"key1": "value1"}
|
|
||||||
|
|
||||||
async def get_data():
|
|
||||||
return json
|
|
||||||
|
|
||||||
base._request = pytest.helpers.request(base.request.app, "", "", data=get_data)
|
|
||||||
assert await base.data_as_json(["key1"]) == {"key1": ["value1"]}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_extract_data_json(base: BaseView) -> None:
|
|
||||||
"""
|
|
||||||
must parse and return json
|
|
||||||
"""
|
|
||||||
json = {"key1": "value1", "key2": "value2"}
|
|
||||||
|
|
||||||
async def get_json():
|
|
||||||
return json
|
|
||||||
|
|
||||||
base._request = pytest.helpers.request(base.request.app, "", "", json=get_json)
|
|
||||||
assert await base.extract_data() == json
|
|
||||||
|
|
||||||
|
|
||||||
async def test_extract_data_post(base: BaseView) -> None:
|
|
||||||
"""
|
|
||||||
must parse and return form data
|
|
||||||
"""
|
|
||||||
json = {"key1": "value1", "key2": "value2"}
|
|
||||||
|
|
||||||
async def get_json():
|
|
||||||
raise ValueError()
|
|
||||||
|
|
||||||
async def get_data():
|
|
||||||
return json
|
|
||||||
|
|
||||||
base._request = pytest.helpers.request(base.request.app, "", "", json=get_json, data=get_data)
|
|
||||||
assert await base.extract_data() == json
|
|
||||||
|
|
||||||
|
|
||||||
async def test_head(client: TestClient) -> None:
|
async def test_head(client: TestClient) -> None:
|
||||||
"""
|
"""
|
||||||
must implement head as get method
|
must implement head as get method
|
||||||
|
@ -63,7 +63,6 @@ async def test_post_empty(client: TestClient, mocker: MockerFixture) -> None:
|
|||||||
"""
|
"""
|
||||||
must call raise 400 on invalid request
|
must call raise 400 on invalid request
|
||||||
"""
|
"""
|
||||||
mocker.patch("ahriman.web.views.base.BaseView.extract_data", side_effect=Exception())
|
|
||||||
update_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_update")
|
update_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_update")
|
||||||
response_schema = pytest.helpers.schema_response(UpdateView.post, code=400)
|
response_schema = pytest.helpers.schema_response(UpdateView.post, code=400)
|
||||||
|
|
||||||
|
@ -125,9 +125,6 @@ async def test_post(client_with_auth: TestClient, user: User, mocker: MockerFixt
|
|||||||
response = await client_with_auth.post("/api/v1/login", json=payload)
|
response = await client_with_auth.post("/api/v1/login", json=payload)
|
||||||
assert response.ok
|
assert response.ok
|
||||||
|
|
||||||
response = await client_with_auth.post("/api/v1/login", data=payload)
|
|
||||||
assert response.ok
|
|
||||||
|
|
||||||
remember_mock.assert_called()
|
remember_mock.assert_called()
|
||||||
|
|
||||||
|
|
||||||
@ -156,3 +153,16 @@ async def test_post_unauthorized(client_with_auth: TestClient, user: User, mocke
|
|||||||
assert response.status == 401
|
assert response.status == 401
|
||||||
assert not response_schema.validate(await response.json())
|
assert not response_schema.validate(await response.json())
|
||||||
remember_mock.assert_not_called()
|
remember_mock.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_invalid_json(client_with_auth: TestClient, mocker: MockerFixture) -> None:
|
||||||
|
"""
|
||||||
|
must return unauthorized on invalid auth
|
||||||
|
"""
|
||||||
|
response_schema = pytest.helpers.schema_response(LoginView.post, code=400)
|
||||||
|
remember_mock = mocker.patch("aiohttp_security.remember")
|
||||||
|
|
||||||
|
response = await client_with_auth.post("/api/v1/login")
|
||||||
|
assert response.status == 400
|
||||||
|
assert not response_schema.validate(await response.json())
|
||||||
|
remember_mock.assert_not_called()
|
||||||
|
Loading…
Reference in New Issue
Block a user