diff --git a/README.md b/README.md index cc41186e..14db39ec 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ turn almost any device into a file server with resumable uploads/downloads using * [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/)) * [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/)) * [identity providers](#identity-providers) - replace copyparty passwords with oauth and such + * [user-changeable passwords](#user-changeable-passwords) - if permitted, users can change their own passwords * [using the cloud as storage](#using-the-cloud-as-storage) - connecting to an aws s3 bucket and similar * [hiding from google](#hiding-from-google) - tell search engines you dont wanna be indexed * [themes](#themes) @@ -1355,6 +1356,29 @@ there is a [docker-compose example](./docs/examples/docker/idp-authelia-traefik) a more complete example of the copyparty configuration options [look like this](./docs/examples/docker/idp/copyparty.conf) +but if you just want to let users change their own passwords, then you probably want [user-changeable passwords](#user-changeable-passwords) instead + + +## user-changeable passwords + +if permitted, users can change their own passwords in the control-panel + +* not compatible with [identity providers](#identity-providers) + +* must be enabled with `--chpw` because account-sharing is a popular usecase + + * if you want to enable the feature but deny password-changing for a specific list of accounts, you can do that with `--chpw-no name1,name2,name3,...` + +* to perform a password reset, edit the server config and give the user another password there, then do a [config reload](#server-config) or server restart + +* the custom passwords are kept in a textfile at filesystem-path `--chpw-db`, by default `chpw.json` in the copyparty config folder + + * if you run multiple copyparty instances with different users you *almost definitely* want to specify separate DBs for each instance + + * if [password hashing](#password-hashing) is enbled, the passwords in the db are also hashed + + * ...which means that all user-defined passwords will be forgotten if you change password-hashing settings + ## using the cloud as storage diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 1f845531..fd02cffb 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1065,6 +1065,17 @@ def add_auth(ap): 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") +def add_chpw(ap): + db_path = os.path.join(E.cfg, "chpw.json") + ap2 = ap.add_argument_group('user-changeable passwords options') + ap2.add_argument("--chpw", action="store_true", help="allow users to change their own passwords") + ap2.add_argument("--chpw-no", metavar="U,U,U", type=u, action="append", help="do not allow password-changes for this comma-separated list of usernames") + ap2.add_argument("--chpw-db", metavar="PATH", type=u, default=db_path, help="where to store the passwords database (if you run multiple copyparty instances, make sure they use different DBs)") + ap2.add_argument("--chpw-len", metavar="N", type=int, default=8, help="minimum password length") + ap2.add_argument("--chpw-v", action="store_true", help="verbose (when loading: list status of each user)") + ap2.add_argument("--chpw-q", action="store_true", help="quiet (when loading: don't print summary)") + + def add_zeroconf(ap): ap2 = ap.add_argument_group("Zeroconf options") ap2.add_argument("-z", action="store_true", help="enable all zeroconf backends (mdns, ssdp)") @@ -1473,6 +1484,7 @@ def run_argparse( add_tls(ap, cert_path) add_cert(ap, cert_path) add_auth(ap) + add_chpw(ap) add_qr(ap, tty) add_zeroconf(ap) add_zc_mdns(ap) diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index d73f8a5c..aa11605f 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -4,6 +4,7 @@ from __future__ import print_function, unicode_literals import argparse import base64 import hashlib +import json import os import re import stat @@ -807,6 +808,7 @@ class AuthSrv(object): self.vfs = VFS(log_func, "", "", AXS(), {}) self.acct: dict[str, str] = {} self.iacct: dict[str, str] = {} + self.defpw: dict[str, str] = {} self.grps: dict[str, list[str]] = {} self.re_pwd: Optional[re.Pattern] = None @@ -1440,6 +1442,8 @@ class AuthSrv(object): raise self.setup_pwhash(acct) + defpw = acct.copy() + self.setup_chpw(acct) # case-insensitive; normalize if WINDOWS: @@ -2069,6 +2073,7 @@ class AuthSrv(object): self.vfs = vfs self.acct = acct + self.defpw = defpw self.grps = grps self.iacct = {v: k for k, v in acct.items()} @@ -2089,6 +2094,96 @@ class AuthSrv(object): MIMES[ext] = mime EXTS.update({v: k for k, v in MIMES.items()}) + def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]: + if not self.args.chpw: + return False, "feature disabled in server config" + + if uname == "*" or uname not in self.defpw: + return False, "not logged in" + + if len(pw) < self.args.chpw_len: + t = "minimum password length: %d characters" + return False, t % (self.args.chpw_len,) + + hpw = self.ah.hash(pw) if self.ah.on else pw + if hpw in self.iacct: + return False, "password is taken" + + with self.mutex: + ap = self.args.chpw_db + if not bos.path.exists(ap): + pwdb = {} + else: + with open(ap, "r", encoding="utf-8") as f: + pwdb = json.load(f) + + pwdb = [x for x in pwdb if x[0] != uname] + pwdb.append((uname, self.defpw[uname], hpw)) + + with open(ap, "w", encoding="utf-8") as f: + json.dump(pwdb, f, separators=(",\n", ": ")) + + self.log("reinitializing due to password-change for user [%s]" % (uname,)) + + if not broker: + # only true for tests + self._reload() + return True, "new password OK" + + broker.ask("_reload_blocking", False, False).get() + return True, "new password OK" + + def setup_chpw(self, acct: dict[str, str]) -> None: + ap = self.args.chpw_db + if not self.args.chpw or not bos.path.exists(ap): + return + + with open(ap, "r", encoding="utf-8") as f: + pwdb = json.load(f) + + u404 = set() + urst = set() + uok = set() + for usr, orig, mod in pwdb: + if usr not in acct: + u404.add(usr) + continue + if acct[usr] != orig: + urst.add(usr) + continue + uok.add(usr) + acct[usr] = mod + + if self.args.chpw_q: + return + + for zs in uok: + urst.discard(zs) + + if not self.args.chpw_v: + t = "chpw: %d loaded, %d default, %d ignored" + self.log(t % (len(uok), len(urst), len(u404))) + return + + msg = "" + if uok: + t = "\033[0mloaded: \033[32m%s" + msg += t % (", ".join(list(uok)),) + if urst: + t = "%s\033[0mdefault: \033[35m%s" + msg += t % ( + ", " if msg else "", + ", ".join(list(urst)), + ) + if u404: + t = "%s\033[0mignored: \033[35m%s" + msg += t % ( + ", " if msg else "", + ", ".join(list(u404)), + ) + + self.log("chpw: " + msg, 6) + def setup_pwhash(self, acct: dict[str, str]) -> None: self.ah = PWHash(self.args) if not self.ah.on: diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 0eb2ad48..6747bc0d 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -2089,6 +2089,9 @@ class HttpCli(object): if act == "zip": return self.handle_zip_post() + if act == "chpw": + return self.handle_chpw() + raise Pebkac(422, 'invalid action "{}"'.format(act)) def handle_zip_post(self) -> bool: @@ -2393,6 +2396,22 @@ class HttpCli(object): self.reply(b"thank") return True + def handle_chpw(self) -> bool: + assert self.parser + pwd = self.parser.require("pw", 64) + self.parser.drop() + + ok, msg = self.asrv.chpw(self.conn.hsrv.broker, self.uname, pwd) + if ok: + ok, msg = self.get_pwd_cookie(pwd) + if ok: + msg = "new password OK" + + redir = "/?h" if ok else "" + html = self.j2s("msg", h1=msg, h2='ack', redir=redir) + self.reply(html.encode("utf-8")) + return True + def handle_login(self) -> bool: assert self.parser pwd = self.parser.require("cppwd", 64) @@ -2417,12 +2436,12 @@ class HttpCli(object): dst += "&" if "?" in dst else "?" dst += "_=1#" + html_escape(uhash, True, True) - msg = self.get_pwd_cookie(pwd) + _, msg = self.get_pwd_cookie(pwd) html = self.j2s("msg", h1=msg, h2='ack', redir=dst) self.reply(html.encode("utf-8")) return True - def get_pwd_cookie(self, pwd: str) -> str: + def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]: hpwd = self.asrv.ah.hash(pwd) uname = self.asrv.iacct.get(hpwd) if uname: @@ -2454,7 +2473,7 @@ class HttpCli(object): ck = gencookie(k, pwd, self.args.R, self.is_https, dur, "; HttpOnly") self.out_headerlist.append(("Set-Cookie", ck)) - return msg + return dur > 0, msg def handle_mkdir(self) -> bool: assert self.parser @@ -3948,6 +3967,7 @@ class HttpCli(object): k304=self.k304(), k304vis=self.args.k304 > 0, ver=S_VERSION if self.args.ver else "", + chpw=self.args.chpw and self.uname != "*", ahttps="" if self.is_https else "https://" + self.host + self.req, ) self.reply(html.encode("utf-8")) diff --git a/copyparty/svchub.py b/copyparty/svchub.py index ff74779f..75e8a014 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -208,6 +208,11 @@ class SvcHub(object): t = "WARNING: --s-rd-sz (%d) is larger than --iobuf (%d); this may lead to reduced performance" self.log("root", t % (args.s_rd_sz, args.iobuf), 3) + if args.chpw and args.idp_h_usr: + t = "ERROR: user-changeable passwords is incompatible with IdP/identity-providers; you must disable either --chpw or --idp-h-usr" + self.log("root", t, 1) + raise Exception(t) + bri = "zy"[args.theme % 2 :][:1] ch = "abcdefghijklmnopqrstuvwx"[int(args.theme / 2)] args.theme = "{0}{1} {0} {1}".format(ch, bri) @@ -815,18 +820,21 @@ class SvcHub(object): Daemon(self._reload, "reloading") return "reload initiated" - def _reload(self, rescan_all_vols: bool = True) -> None: + def _reload(self, rescan_all_vols: bool = True, up2k: bool = True) -> None: with self.up2k.mutex: if self.reloading != 1: return self.reloading = 2 self.log("root", "reloading config") self.asrv.reload() - self.up2k.reload(rescan_all_vols) + if up2k: + self.up2k.reload(rescan_all_vols) + else: + self.log("root", "reload done") self.broker.reload() self.reloading = 0 - def _reload_blocking(self, rescan_all_vols: bool = True) -> None: + def _reload_blocking(self, rescan_all_vols: bool = True, up2k: bool = True) -> None: while True: with self.up2k.mutex: if self.reloading < 2: @@ -837,7 +845,7 @@ class SvcHub(object): # try to handle multiple pending IdP reloads at once: time.sleep(0.2) - self._reload(rescan_all_vols=rescan_all_vols) + self._reload(rescan_all_vols=rescan_all_vols, up2k=up2k) def stop_thr(self) -> None: while not self.stop_req: diff --git a/copyparty/web/splash.css b/copyparty/web/splash.css index 8c5b8705..7393295e 100644 --- a/copyparty/web/splash.css +++ b/copyparty/web/splash.css @@ -182,13 +182,15 @@ html.z a.g { border-color: #af4; box-shadow: 0 .3em 1em #7d0; } +#x, input { color: #a50; background: #fff; border: 1px solid #a50; - border-radius: .5em; - padding: .5em .7em; - margin: 0 .5em 0 0; + border-radius: .3em; + padding: .3em .6em; + margin: 0 .3em 0 0; + font-size: 1em; } input::placeholder { font-size: 1.2em; @@ -197,6 +199,7 @@ input::placeholder { opacity: 0.64; color: #930; } +#x, html.z input { color: #fff; background: #626; diff --git a/copyparty/web/splash.html b/copyparty/web/splash.html index 5ce62b57..31f149db 100644 --- a/copyparty/web/splash.html +++ b/copyparty/web/splash.html @@ -92,11 +92,14 @@