diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index 8ce94767..70bd1f76 100644
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -1067,6 +1067,7 @@ def add_cert(ap, cert_path):
def add_auth(ap):
+ ses_db = os.path.join(E.cfg, "sessions.db")
ap2 = ap.add_argument_group('IdP / identity provider / user authentication options')
ap2.add_argument("--idp-h-usr", metavar="HN", type=u, default="", help="bypass the copyparty authentication checks and assume the request-header \033[33mHN\033[0m contains the username of the requesting user (for use with authentik/oauth/...)\n\033[1;31mWARNING:\033[0m if you enable this, make sure clients are unable to specify this header themselves; must be washed away and replaced by a reverse-proxy")
ap2.add_argument("--idp-h-grp", metavar="HN", type=u, default="", help="assume the request-header \033[33mHN\033[0m contains the groupname of the requesting user; can be referenced in config files for group-based access control")
@@ -1074,6 +1075,9 @@ def add_auth(ap):
ap2.add_argument("--idp-gsep", metavar="RE", type=u, default="|:;+,", help="if there are multiple groups in \033[33m--idp-h-grp\033[0m, they are separated by one of the characters in \033[33mRE\033[0m")
ap2.add_argument("--no-bauth", action="store_true", help="disable basic-authentication support; do not accept passwords from the 'Authenticate' header at all. NOTE: This breaks support for the android app")
ap2.add_argument("--bauth-last", action="store_true", help="keeps basic-authentication enabled, but only as a last-resort; if a cookie is also provided then the cookie wins")
+ ap2.add_argument("--ses-db", metavar="PATH", type=u, default=ses_db, help="where to store the sessions database (if you run multiple copyparty instances, make sure they use different DBs)")
+ ap2.add_argument("--ses-len", metavar="CHARS", type=int, default=20, help="session key length; default is 120 bits ((20//4)*4*6)")
+ ap2.add_argument("--no-ses", action="store_true", help="disable sessions; use plaintext passwords in cookies")
def add_chpw(ap):
diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py
index cbdc5440..0808095c 100644
--- a/copyparty/authsrv.py
+++ b/copyparty/authsrv.py
@@ -840,8 +840,10 @@ class AuthSrv(object):
# fwd-decl
self.vfs = VFS(log_func, "", "", AXS(), {})
- self.acct: dict[str, str] = {}
- self.iacct: dict[str, str] = {}
+ self.acct: dict[str, str] = {} # uname->pw
+ self.iacct: dict[str, str] = {} # pw->uname
+ self.ases: dict[str, str] = {} # uname->session
+ self.sesa: dict[str, str] = {} # session->uname
self.defpw: dict[str, str] = {}
self.grps: dict[str, list[str]] = {}
self.re_pwd: Optional[re.Pattern] = None
@@ -2181,8 +2183,11 @@ class AuthSrv(object):
self.grps = grps
self.iacct = {v: k for k, v in acct.items()}
+ self.load_sessions()
+
self.re_pwd = None
pwds = [re.escape(x) for x in self.iacct.keys()]
+ pwds.extend(list(self.sesa))
if pwds:
if self.ah.on:
zs = r"(\[H\] pw:.*|[?&]pw=)([^&]+)"
@@ -2257,6 +2262,72 @@ class AuthSrv(object):
cur.close()
db.close()
+ def load_sessions(self, quiet=False) -> None:
+ # mutex me
+ if self.args.no_ses:
+ self.ases = {}
+ self.sesa = {}
+ return
+
+ import sqlite3
+
+ ases = {}
+ blen = (self.args.ses_len // 4) * 4 # 3 bytes in 4 chars
+ blen = (blen * 3) // 4 # bytes needed for ses_len chars
+
+ db = sqlite3.connect(self.args.ses_db)
+ cur = db.cursor()
+
+ for uname, sid in cur.execute("select un, si from us"):
+ if uname in self.acct:
+ ases[uname] = sid
+
+ n = []
+ q = "insert into us values (?,?,?)"
+ for uname in self.acct:
+ if uname not in ases:
+ sid = ub64enc(os.urandom(blen)).decode("utf-8")
+ cur.execute(q, (uname, sid, int(time.time())))
+ ases[uname] = sid
+ n.append(uname)
+
+ if n:
+ db.commit()
+
+ cur.close()
+ db.close()
+
+ self.ases = ases
+ self.sesa = {v: k for k, v in ases.items()}
+ if n and not quiet:
+ t = ", ".join(n[:3])
+ if len(n) > 3:
+ t += "..."
+ self.log("added %d new sessions (%s)" % (len(n), t))
+
+ def forget_session(self, broker: Optional["BrokerCli"], uname: str) -> None:
+ with self.mutex:
+ self._forget_session(uname)
+
+ if broker:
+ broker.ask("_reload_sessions").get()
+
+ def _forget_session(self, uname: str) -> None:
+ if self.args.no_ses:
+ return
+
+ import sqlite3
+
+ db = sqlite3.connect(self.args.ses_db)
+ cur = db.cursor()
+ cur.execute("delete from us where un = ?", (uname,))
+ db.commit()
+ cur.close()
+ db.close()
+
+ self.sesa.pop(self.ases.get(uname, ""), "")
+ self.ases.pop(uname, "")
+
def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]:
if not self.args.chpw:
return False, "feature disabled in server config"
@@ -2276,7 +2347,7 @@ class AuthSrv(object):
if hpw == self.acct[uname]:
return False, "that's already your password my dude"
- if hpw in self.iacct:
+ if hpw in self.iacct or hpw in self.sesa:
return False, "password is taken"
with self.mutex:
diff --git a/copyparty/broker_mp.py b/copyparty/broker_mp.py
index b09f6ce3..57b2506c 100644
--- a/copyparty/broker_mp.py
+++ b/copyparty/broker_mp.py
@@ -76,6 +76,10 @@ class BrokerMp(object):
for _, proc in enumerate(self.procs):
proc.q_pend.put((0, "reload", []))
+ def reload_sessions(self) -> None:
+ for _, proc in enumerate(self.procs):
+ proc.q_pend.put((0, "reload_sessions", []))
+
def collector(self, proc: MProcess) -> None:
"""receive message from hub in other process"""
while True:
diff --git a/copyparty/broker_mpw.py b/copyparty/broker_mpw.py
index e74c4547..2a961fa3 100644
--- a/copyparty/broker_mpw.py
+++ b/copyparty/broker_mpw.py
@@ -94,6 +94,10 @@ class MpWorker(BrokerCli):
self.asrv.reload()
self.logw("mpw.asrv reloaded")
+ elif dest == "reload_sessions":
+ with self.asrv.mutex:
+ self.asrv.load_sessions()
+
elif dest == "listen":
self.httpsrv.listen(args[0], args[1])
diff --git a/copyparty/broker_thr.py b/copyparty/broker_thr.py
index e40cd38d..9d3edd5c 100644
--- a/copyparty/broker_thr.py
+++ b/copyparty/broker_thr.py
@@ -34,6 +34,7 @@ class BrokerThr(BrokerCli):
self.iphash = HMaccas(os.path.join(self.args.E.cfg, "iphash"), 8)
self.httpsrv = HttpSrv(self, None)
self.reload = self.noop
+ self.reload_sessions = self.noop
def shutdown(self) -> None:
# self.log("broker", "shutting down")
diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index 6e0335ca..ca1b5a58 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -205,7 +205,8 @@ class HttpCli(object):
def unpwd(self, m: Match[str]) -> str:
a, b, c = m.groups()
- return "%s\033[7m %s \033[27m%s" % (a, self.asrv.iacct[b], c)
+ uname = self.asrv.iacct.get(b) or self.asrv.sesa.get(b)
+ return "%s\033[7m %s \033[27m%s" % (a, uname, c)
def _check_nonfatal(self, ex: Pebkac, post: bool) -> bool:
if post:
@@ -504,6 +505,8 @@ class HttpCli(object):
zs = base64.b64decode(zb).decode("utf-8")
# try "pwd", "x:pwd", "pwd:x"
for bauth in [zs] + zs.split(":", 1)[::-1]:
+ if bauth in self.asrv.sesa:
+ break
hpw = self.asrv.ah.hash(bauth)
if self.asrv.iacct.get(hpw):
break
@@ -565,7 +568,11 @@ class HttpCli(object):
self.uname = "*"
else:
self.pw = uparam.get("pw") or self.headers.get("pw") or bauth or cookie_pw
- self.uname = self.asrv.iacct.get(self.asrv.ah.hash(self.pw)) or "*"
+ self.uname = (
+ self.asrv.sesa.get(self.pw)
+ or self.asrv.iacct.get(self.asrv.ah.hash(self.pw))
+ or "*"
+ )
self.rvol = self.asrv.vfs.aread[self.uname]
self.wvol = self.asrv.vfs.awrite[self.uname]
@@ -2088,6 +2095,9 @@ class HttpCli(object):
if act == "chpw":
return self.handle_chpw()
+ if act == "logout":
+ return self.handle_logout()
+
raise Pebkac(422, 'invalid action "{}"'.format(act))
def handle_zip_post(self) -> bool:
@@ -2409,7 +2419,8 @@ class HttpCli(object):
msg = "new password OK"
redir = (self.args.SRS + "?h") if ok else ""
- html = self.j2s("msg", h1=msg, h2='ack', redir=redir)
+ h2 = 'ack'
+ html = self.j2s("msg", h1=msg, h2=h2, redir=redir)
self.reply(html.encode("utf-8"))
return True
@@ -2422,9 +2433,8 @@ class HttpCli(object):
uhash = ""
self.parser.drop()
- self.out_headerlist = [
- x for x in self.out_headerlist if x[0] != "Set-Cookie" or "cppw" != x[1][:4]
- ]
+ if not pwd:
+ raise Pebkac(422, "password cannot be blank")
dst = self.args.SRS
if self.vpath:
@@ -2442,9 +2452,27 @@ class HttpCli(object):
self.reply(html.encode("utf-8"))
return True
+ def handle_logout(self) -> bool:
+ assert self.parser
+ self.parser.drop()
+
+ self.log("logout " + self.uname)
+ self.asrv.forget_session(self.conn.hsrv.broker, self.uname)
+ self.get_pwd_cookie("x")
+
+ dst = self.args.SRS + "?h"
+ h2 = 'ack'
+ html = self.j2s("msg", h1="ok bye", h2=h2, redir=dst)
+ self.reply(html.encode("utf-8"))
+ return True
+
def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]:
- hpwd = self.asrv.ah.hash(pwd)
- uname = self.asrv.iacct.get(hpwd)
+ uname = self.asrv.sesa.get(pwd)
+ if not uname:
+ hpwd = self.asrv.ah.hash(pwd)
+ uname = self.asrv.iacct.get(hpwd)
+ if uname:
+ pwd = self.asrv.ases.get(uname) or pwd
if uname:
msg = "hi " + uname
dur = int(60 * 60 * self.args.logout)
@@ -2456,8 +2484,9 @@ class HttpCli(object):
zb = hashlib.sha512(pwd.encode("utf-8", "replace")).digest()
logpwd = "%" + base64.b64encode(zb[:12]).decode("utf-8")
- self.log("invalid password: {}".format(logpwd), 3)
- self.cbonk(self.conn.hsrv.gpwd, pwd, "pw", "invalid passwords")
+ if pwd != "x":
+ self.log("invalid password: {}".format(logpwd), 3)
+ self.cbonk(self.conn.hsrv.gpwd, pwd, "pw", "invalid passwords")
msg = "naw dude"
pwd = "x" # nosec
@@ -2469,10 +2498,11 @@ class HttpCli(object):
for k in ("cppwd", "cppws") if self.is_https else ("cppwd",):
ck = gencookie(k, pwd, self.args.R, False)
self.out_headerlist.append(("Set-Cookie", ck))
+ self.out_headers.pop("Set-Cookie", None) # drop keepalive
else:
k = "cppws" if self.is_https else "cppwd"
ck = gencookie(k, pwd, self.args.R, self.is_https, dur, "; HttpOnly")
- self.out_headerlist.append(("Set-Cookie", ck))
+ self.out_headers["Set-Cookie"] = ck
return dur > 0, msg
diff --git a/copyparty/svchub.py b/copyparty/svchub.py
index 71bcb34b..98409b83 100644
--- a/copyparty/svchub.py
+++ b/copyparty/svchub.py
@@ -221,6 +221,9 @@ class SvcHub(object):
noch.update([x for x in zsl if x])
args.chpw_no = noch
+ if not self.args.no_ses:
+ self.setup_session_db()
+
if args.shr:
self.setup_share_db()
@@ -369,6 +372,64 @@ class SvcHub(object):
self.broker = Broker(self)
+ def setup_session_db(self) -> None:
+ if not HAVE_SQLITE3:
+ self.args.no_ses = True
+ t = "WARNING: sqlite3 not available; disabling sessions, will use plaintext passwords in cookies"
+ self.log("root", t, 3)
+ return
+
+ import sqlite3
+
+ create = True
+ db_path = self.args.ses_db
+ self.log("root", "opening sessions-db %s" % (db_path,))
+ for n in range(2):
+ try:
+ db = sqlite3.connect(db_path)
+ cur = db.cursor()
+ try:
+ cur.execute("select count(*) from us").fetchone()
+ create = False
+ break
+ except:
+ pass
+ except Exception as ex:
+ if n:
+ raise
+ t = "sessions-db corrupt; deleting and recreating: %r"
+ self.log("root", t % (ex,), 3)
+ try:
+ cur.close() # type: ignore
+ except:
+ pass
+ try:
+ db.close() # type: ignore
+ except:
+ pass
+ os.unlink(db_path)
+
+ sch = [
+ r"create table kv (k text, v int)",
+ r"create table us (un text, si text, t0 int)",
+ # username, session-id, creation-time
+ r"create index us_un on us(un)",
+ r"create index us_si on us(si)",
+ r"create index us_t0 on us(t0)",
+ r"insert into kv values ('sver', 1)",
+ ]
+
+ assert db # type: ignore
+ assert cur # type: ignore
+ if create:
+ for cmd in sch:
+ cur.execute(cmd)
+ self.log("root", "created new sessions-db")
+ db.commit()
+
+ cur.close()
+ db.close()
+
def setup_share_db(self) -> None:
al = self.args
if not HAVE_SQLITE3:
@@ -545,7 +606,7 @@ class SvcHub(object):
fng = []
t_ff = "transcode audio, create spectrograms, video thumbnails"
to_check = [
- (HAVE_SQLITE3, "sqlite", "file and media indexing"),
+ (HAVE_SQLITE3, "sqlite", "sessions and file/media indexing"),
(HAVE_PIL, "pillow", "image thumbnails (plenty fast)"),
(HAVE_VIPS, "vips", "image thumbnails (faster, eats more ram)"),
(HAVE_WEBP, "pillow-webp", "create thumbnails as webp files"),
@@ -945,6 +1006,11 @@ class SvcHub(object):
self._reload(rescan_all_vols=rescan_all_vols, up2k=up2k)
+ def _reload_sessions(self) -> None:
+ with self.asrv.mutex:
+ self.asrv.load_sessions(True)
+ self.broker.reload_sessions()
+
def stop_thr(self) -> None:
while not self.stop_req:
with self.stop_cond:
diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css
index 35bb937a..7035188c 100644
--- a/copyparty/web/browser.css
+++ b/copyparty/web/browser.css
@@ -604,7 +604,7 @@ html.dy {
background: var(--sel-bg);
text-shadow: none;
}
-html,body,tr,th,td,#files,a {
+html,body,tr,th,td,#files,a,#blogout {
color: inherit;
background: none;
font-weight: inherit;
@@ -687,11 +687,15 @@ html.y #path {
#files tbody div a {
color: var(--tab-alt);
}
-a, #files tbody div a:last-child {
+a, #blogout, #files tbody div a:last-child {
color: var(--a);
padding: .2em;
text-decoration: none;
}
+#blogout {
+ margin: -.2em;
+}
+#blogout:hover,
a:hover {
color: var(--a-hil);
background: var(--a-h-bg);
@@ -935,6 +939,9 @@ html.y #path a:hover {
color: var(--srv-3);
border-bottom: 1px solid var(--srv-3b);
}
+#flogout {
+ display: inline;
+}
#goh+span {
color: var(--bg-u5);
padding-left: .5em;
diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js
index 96f8d928..930f4c5d 100644
--- a/copyparty/web/browser.js
+++ b/copyparty/web/browser.js
@@ -7787,7 +7787,7 @@ function apply_perms(res) {
ebi('acc_info').innerHTML = '' + srvinf +
'' + (acct != '*' ?
- '' + (window.is_idp ? '' : L.logout) + acct + '' :
+ '' :
'Login');
var o = QSA('#ops>a[data-perm]');
diff --git a/tests/util.py b/tests/util.py
index ef4540a6..a824ff37 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -120,7 +120,7 @@ class Cfg(Namespace):
ex = "chpw daw dav_auth dav_inf dav_mac dav_rt e2d e2ds e2dsa e2t e2ts e2tsr e2v e2vu e2vp early_ban ed emp exp force_js getmod grid gsel hardlink ih ihead magic hardlink_only nid nih no_acode no_athumb no_dav no_db_ip no_del no_dupe no_lifetime no_logues no_mv no_pipe no_poll no_readme no_robots no_sb_md no_sb_lg no_scandir no_tarcmp no_thumb no_vthumb no_zip nrand nw og og_no_head og_s_title q rand smb srch_dbg stats uqe vague_403 vc ver write_uplog xdev xlink xvol zs"
ka.update(**{k: False for k in ex.split()})
- ex = "dedup dotpart dotsrch hook_v no_dhash no_fastboot no_fpool no_htp no_rescan no_sendfile no_snap no_voldump re_dhash plain_ip"
+ ex = "dedup dotpart dotsrch hook_v no_dhash no_fastboot no_fpool no_htp no_rescan no_sendfile no_ses no_snap no_voldump re_dhash plain_ip"
ka.update(**{k: True for k in ex.split()})
ex = "ah_cli ah_gen css_browser hist js_browser js_other mime mimes no_forget no_hash no_idx nonsus_urls og_tpl og_ua"