pikvm/pikvm#1204: Expire user session

This commit is contained in:
Maxim Devaev 2025-02-08 23:30:52 +02:00
parent abbd65a9a0
commit a7c3cdc1ea
8 changed files with 225 additions and 57 deletions

View File

@ -34,6 +34,7 @@ from ....htserver import set_request_auth_info
from ....validators.auth import valid_user
from ....validators.auth import valid_passwd
from ....validators.auth import valid_expire
from ....validators.auth import valid_auth_token
from ..auth import AuthManager
@ -91,6 +92,7 @@ class AuthApi:
token = await self.__auth_manager.login(
user=valid_user(credentials.get("user", "")),
passwd=valid_passwd(credentials.get("passwd", "")),
expire=valid_expire(credentials.get("expire", "0")),
)
if token:
return make_json_response(set_cookies={_COOKIE_AUTH_TOKEN: token})

View File

@ -20,6 +20,9 @@
# ========================================================================== #
import dataclasses
import time
import secrets
import pyotp
@ -34,6 +37,17 @@ from ...htserver import HttpExposed
# =====
@dataclasses.dataclass(frozen=True)
class _Session:
user: str
expire_ts: int
def __post_init__(self) -> None:
assert self.user.strip()
assert self.user
assert self.expire_ts >= 0
class AuthManager:
def __init__(
self,
@ -72,7 +86,7 @@ class AuthManager:
self.__totp_secret_path = totp_secret_path
self.__tokens: dict[str, str] = {} # {token: user}
self.__sessions: dict[str, _Session] = {} # {token: session}
def is_auth_enabled(self) -> bool:
return self.__enabled
@ -106,20 +120,26 @@ class AuthManager:
service = self.__internal_service
ok = (await service.authorize(user, passwd))
pname = service.get_plugin_name()
if ok:
get_logger().info("Authorized user %r via auth service %r", user, service.get_plugin_name())
get_logger().info("Authorized user %r via auth service %r", user, pname)
else:
get_logger().error("Got access denied for user %r from auth service %r", user, service.get_plugin_name())
get_logger().error("Got access denied for user %r from auth service %r", user, pname)
return ok
async def login(self, user: str, passwd: str) -> (str | None):
async def login(self, user: str, passwd: str, expire: int) -> (str | None):
assert user == user.strip()
assert user
assert expire >= 0
assert self.__enabled
if (await self.authorize(user, passwd)):
token = self.__make_new_token()
self.__tokens[token] = user
get_logger().info("Logged in user %r", user)
session = _Session(
user=user,
expire_ts=(0 if expire <= 0 else (self.__get_now_ts() + expire)),
)
self.__sessions[token] = session
get_logger().info("Logged in user %r (expire_ts=%d)", session.user, session.expire_ts)
return token
else:
return None
@ -127,24 +147,40 @@ class AuthManager:
def __make_new_token(self) -> str:
for _ in range(10):
token = secrets.token_hex(32)
if token not in self.__tokens:
if token not in self.__sessions:
return token
raise AssertionError("Can't generate new unique token")
def __get_now_ts(self) -> int:
return int(time.monotonic())
def logout(self, token: str) -> None:
assert self.__enabled
if token in self.__tokens:
user = self.__tokens[token]
if token in self.__sessions:
user = self.__sessions[token].user
count = 0
for (r_token, r_user) in list(self.__tokens.items()):
if r_user == user:
for (key_t, session) in list(self.__sessions.items()):
if session.user == user:
count += 1
del self.__tokens[r_token]
get_logger().info("Logged out user %r (%d)", user, count)
del self.__sessions[key_t]
get_logger().info("Logged out user %r (was=%d)", user, count)
def check(self, token: str) -> (str | None):
assert self.__enabled
return self.__tokens.get(token)
session = self.__sessions.get(token)
if session is not None:
if session.expire_ts <= 0:
# Infinite session
assert session.user
return session.user
else:
# Limited session
if self.__get_now_ts() < session.expire_ts:
assert session.user
return session.user
else:
del self.__sessions[token]
return None
@aiotools.atomic_fg
async def cleanup(self) -> None:

View File

@ -23,6 +23,7 @@
from typing import Any
from .basic import valid_string_list
from .basic import valid_number
from . import check_re_match
@ -40,5 +41,9 @@ def valid_passwd(arg: Any) -> str:
return check_re_match(arg, "passwd characters", r"^[\x20-\x7e]*\Z$", strip=False, hide=True)
def valid_expire(arg: Any) -> int:
return int(valid_number(arg, min=0, name="expiration time"))
def valid_auth_token(arg: Any) -> str:
return check_re_match(arg, "auth token", r"^[0-9a-f]{64}$", hide=True)

View File

@ -21,6 +21,7 @@
import os
import asyncio
import contextlib
from typing import AsyncGenerator
@ -79,6 +80,64 @@ async def _get_configured_manager(
# =====
@pytest.mark.asyncio
async def test_ok__expire(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
htpasswd = passlib.apache.HtpasswdFile(path, new=True)
htpasswd.set_password("admin", "pass")
htpasswd.save()
async with _get_configured_manager([], path) as manager:
assert manager.is_auth_enabled()
assert manager.is_auth_required(_E_AUTH)
assert manager.is_auth_required(_E_UNAUTH)
assert not manager.is_auth_required(_E_FREE)
assert manager.check("xxx") is None
manager.logout("xxx")
assert (await manager.login("user", "foo", 3)) is None
assert (await manager.login("admin", "foo", 3)) is None
assert (await manager.login("user", "pass", 3)) is None
token1 = await manager.login("admin", "pass", 3)
assert isinstance(token1, str)
assert len(token1) == 64
token2 = await manager.login("admin", "pass", 3)
assert isinstance(token2, str)
assert len(token2) == 64
assert token1 != token2
assert manager.check(token1) == "admin"
assert manager.check(token2) == "admin"
assert manager.check("foobar") is None
manager.logout(token1)
assert manager.check(token1) is None
assert manager.check(token2) is None
assert manager.check("foobar") is None
token3 = await manager.login("admin", "pass", 3)
assert isinstance(token3, str)
assert len(token3) == 64
assert token1 != token3
assert token2 != token3
await asyncio.sleep(4)
assert manager.check(token1) is None
assert manager.check(token2) is None
assert manager.check(token3) is None
# Check for removed token
assert manager.check(token1) is None
assert manager.check(token2) is None
assert manager.check(token3) is None
@pytest.mark.asyncio
async def test_ok__internal(tmpdir) -> None: # type: ignore
path = os.path.abspath(str(tmpdir.join("htpasswd")))
@ -96,15 +155,15 @@ async def test_ok__internal(tmpdir) -> None: # type: ignore
assert manager.check("xxx") is None
manager.logout("xxx")
assert (await manager.login("user", "foo")) is None
assert (await manager.login("admin", "foo")) is None
assert (await manager.login("user", "pass")) is None
assert (await manager.login("user", "foo", 0)) is None
assert (await manager.login("admin", "foo", 0)) is None
assert (await manager.login("user", "pass", 0)) is None
token1 = await manager.login("admin", "pass")
token1 = await manager.login("admin", "pass", 0)
assert isinstance(token1, str)
assert len(token1) == 64
token2 = await manager.login("admin", "pass")
token2 = await manager.login("admin", "pass", 0)
assert isinstance(token2, str)
assert len(token2) == 64
assert token1 != token2
@ -119,7 +178,7 @@ async def test_ok__internal(tmpdir) -> None: # type: ignore
assert manager.check(token2) is None
assert manager.check("foobar") is None
token3 = await manager.login("admin", "pass")
token3 = await manager.login("admin", "pass", 0)
assert isinstance(token3, str)
assert len(token3) == 64
assert token1 != token3
@ -147,17 +206,17 @@ async def test_ok__external(tmpdir) -> None: # type: ignore
assert manager.is_auth_required(_E_UNAUTH)
assert not manager.is_auth_required(_E_FREE)
assert (await manager.login("local", "foobar")) is None
assert (await manager.login("admin", "pass2")) is None
assert (await manager.login("local", "foobar", 0)) is None
assert (await manager.login("admin", "pass2", 0)) is None
token = await manager.login("admin", "pass1")
token = await manager.login("admin", "pass1", 0)
assert token is not None
assert manager.check(token) == "admin"
manager.logout(token)
assert manager.check(token) is None
token = await manager.login("user", "foobar")
token = await manager.login("user", "foobar", 0)
assert token is not None
assert manager.check(token) == "user"
@ -212,7 +271,7 @@ async def test_ok__disabled() -> None:
await manager.authorize("admin", "admin")
with pytest.raises(AssertionError):
await manager.login("admin", "admin")
await manager.login("admin", "admin", 0)
with pytest.raises(AssertionError):
manager.logout("xxx")

View File

@ -28,6 +28,7 @@ from kvmd.validators import ValidatorError
from kvmd.validators.auth import valid_user
from kvmd.validators.auth import valid_users_list
from kvmd.validators.auth import valid_passwd
from kvmd.validators.auth import valid_expire
from kvmd.validators.auth import valid_auth_token
@ -109,6 +110,20 @@ def test_fail__valid_passwd(arg: Any) -> None:
print(valid_passwd(arg))
# =====
@pytest.mark.parametrize("arg", ["0 ", 0, 1, 13])
def test_ok__valid_expire(arg: Any) -> None:
value = valid_expire(arg)
assert type(value) is int # pylint: disable=unidiomatic-typecheck
assert value == int(str(arg).strip())
@pytest.mark.parametrize("arg", ["test", "", None, -1, -13, 1.1])
def test_fail__valid_expire(arg: Any) -> None:
with pytest.raises(ValidatorError):
print(valid_expire(arg))
# =====
@pytest.mark.parametrize("arg", [
("0" * 64) + " ",

View File

@ -37,6 +37,7 @@
<link rel="stylesheet" href="../share/css/main.css">
<link rel="stylesheet" href="../share/css/window.css">
<link rel="stylesheet" href="../share/css/modal.css">
<link rel="stylesheet" href="../share/css/radio.css">
<link rel="stylesheet" href="../share/css/login/login.css">
<link rel="stylesheet" href="../share/css/user.css">
<script type="module">import {setRootPrefix} from "../share/js/vars.js";
@ -73,6 +74,24 @@
<hr>
</td>
</tr>
<tr>
<td>Remember me:&nbsp;</td>
<td>
<div class="radio-box">
<input type="radio" id="expire-radio-3600" name="expire-radio" value="3600"/>
<label for="expire-radio-3600">1h</label>
<input type="radio" id="expire-radio-86400" name="expire-radio" value="86400"/>
<label for="expire-radio-86400">24h</label>
<input type="radio" id="expire-radio-0" name="expire-radio" value="0" checked="checked"/>
<label for="expire-radio-0">Forever</label>
</div>
</td>
</tr>
<tr>
<td colspan="2">
<hr>
</td>
</tr>
<tr>
<td></td>
<td>

View File

@ -1,12 +1,22 @@
extends ../base.pug
mixin radio(name, items)
.radio-box
each item in items
-
let id = `${name}-${item["value"]}`
let checked = (item["checked"] || false)
input(type="radio" id=id name=name value=item["value"] checked=checked)
label(for=id) !{item["title"]}
append vars
-
root_prefix = "../"
title = "PiKVM Login"
main_js = "login/main"
css_list.push("window", "modal", "login/login")
css_list.push("window", "modal", "radio", "login/login")
block body
@ -26,6 +36,17 @@ block body
tr
td(colspan=2)
hr
tr
td Remember me:&nbsp;
td
+radio("expire-radio", [
{"title": "1h", "value": "3600"},
{"title": "24h", "value": "86400"},
{"title": "Forever", "value": "0", "checked": true},
])
tr
td(colspan=2)
hr
tr
td
td #[button.key#login-button(style="width:100%") Login]

View File

@ -32,6 +32,13 @@ export function main() {
if (checkBrowser(null, null)) {
initWindowManager();
// Radio is a string container
tools.radio.clickValue("expire-radio", tools.storage.get("login.expire", 0));
tools.radio.setOnClick("expire-radio", function() {
let expire = parseInt(tools.radio.getValue("expire-radio"));
tools.storage.setInt("login.expire", expire);
}, false);
tools.el.setOnClick($("login-button"), __login);
$("user-input").onkeyup = $("passwd-input").onkeyup = $("code-input").onkeyup = function(event) {
if (event.code === "Enter") {
@ -45,12 +52,16 @@ export function main() {
}
function __login() {
let user = $("user-input").value;
if (user.length === 0) {
let e_user = encodeURIComponent($("user-input").value);
if (e_user.length === 0) {
$("user-input").focus();
} else {
let passwd = $("passwd-input").value + $("code-input").value;
let body = `user=${encodeURIComponent(user)}&passwd=${encodeURIComponent(passwd)}`;
return;
}
let e_passwd = encodeURIComponent($("passwd-input").value + $("code-input").value);
let e_expire = encodeURIComponent(tools.radio.getValue("expire-radio"));
let body = `user=${e_user}&passwd=${e_passwd}&expire=${e_expire}`;
tools.httpPost("api/auth/login", null, function(http) {
switch (http.status) {
case 200:
@ -76,9 +87,9 @@ function __login() {
} break;
}
}, body, "application/x-www-form-urlencoded");
__setEnabled(false);
}
}
function __setEnabled(enabled) {
tools.el.setEnabled($("user-input"), enabled);