Skip to content

Commit 83fb569

Browse files
committed
make passwords user-changeable; closes #92
1 parent 5a62cb4 commit 83fb569

File tree

9 files changed

+227
-16
lines changed

9 files changed

+227
-16
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ turn almost any device into a file server with resumable uploads/downloads using
7676
* [upload events](#upload-events) - the older, more powerful approach ([examples](./bin/mtag/))
7777
* [handlers](#handlers) - redefine behavior with plugins ([examples](./bin/handlers/))
7878
* [identity providers](#identity-providers) - replace copyparty passwords with oauth and such
79+
* [user-changeable passwords](#user-changeable-passwords) - if permitted, users can change their own passwords
7980
* [using the cloud as storage](#using-the-cloud-as-storage) - connecting to an aws s3 bucket and similar
8081
* [hiding from google](#hiding-from-google) - tell search engines you dont wanna be indexed
8182
* [themes](#themes)
@@ -1355,6 +1356,29 @@ there is a [docker-compose example](./docs/examples/docker/idp-authelia-traefik)
13551356
13561357
a more complete example of the copyparty configuration options [look like this](./docs/examples/docker/idp/copyparty.conf)
13571358
1359+
but if you just want to let users change their own passwords, then you probably want [user-changeable passwords](#user-changeable-passwords) instead
1360+
1361+
1362+
## user-changeable passwords
1363+
1364+
if permitted, users can change their own passwords in the control-panel
1365+
1366+
* not compatible with [identity providers](#identity-providers)
1367+
1368+
* must be enabled with `--chpw` because account-sharing is a popular usecase
1369+
1370+
* 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,...`
1371+
1372+
* 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
1373+
1374+
* the custom passwords are kept in a textfile at filesystem-path `--chpw-db`, by default `chpw.json` in the copyparty config folder
1375+
1376+
* if you run multiple copyparty instances with different users you *almost definitely* want to specify separate DBs for each instance
1377+
1378+
* if [password hashing](#password-hashing) is enbled, the passwords in the db are also hashed
1379+
1380+
* ...which means that all user-defined passwords will be forgotten if you change password-hashing settings
1381+
13581382
13591383
## using the cloud as storage
13601384

copyparty/__main__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,17 @@ def add_auth(ap):
10651065
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")
10661066

10671067

1068+
def add_chpw(ap):
1069+
db_path = os.path.join(E.cfg, "chpw.json")
1070+
ap2 = ap.add_argument_group('user-changeable passwords options')
1071+
ap2.add_argument("--chpw", action="store_true", help="allow users to change their own passwords")
1072+
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")
1073+
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)")
1074+
ap2.add_argument("--chpw-len", metavar="N", type=int, default=8, help="minimum password length")
1075+
ap2.add_argument("--chpw-v", action="store_true", help="verbose (when loading: list status of each user)")
1076+
ap2.add_argument("--chpw-q", action="store_true", help="quiet (when loading: don't print summary)")
1077+
1078+
10681079
def add_zeroconf(ap):
10691080
ap2 = ap.add_argument_group("Zeroconf options")
10701081
ap2.add_argument("-z", action="store_true", help="enable all zeroconf backends (mdns, ssdp)")
@@ -1473,6 +1484,7 @@ def run_argparse(
14731484
add_tls(ap, cert_path)
14741485
add_cert(ap, cert_path)
14751486
add_auth(ap)
1487+
add_chpw(ap)
14761488
add_qr(ap, tty)
14771489
add_zeroconf(ap)
14781490
add_zc_mdns(ap)

copyparty/authsrv.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import argparse
55
import base64
66
import hashlib
7+
import json
78
import os
89
import re
910
import stat
@@ -807,6 +808,7 @@ def __init__(
807808
self.vfs = VFS(log_func, "", "", AXS(), {})
808809
self.acct: dict[str, str] = {}
809810
self.iacct: dict[str, str] = {}
811+
self.defpw: dict[str, str] = {}
810812
self.grps: dict[str, list[str]] = {}
811813
self.re_pwd: Optional[re.Pattern] = None
812814

@@ -1440,6 +1442,8 @@ def _reload(self) -> None:
14401442
raise
14411443

14421444
self.setup_pwhash(acct)
1445+
defpw = acct.copy()
1446+
self.setup_chpw(acct)
14431447

14441448
# case-insensitive; normalize
14451449
if WINDOWS:
@@ -2069,6 +2073,7 @@ def _reload(self) -> None:
20692073

20702074
self.vfs = vfs
20712075
self.acct = acct
2076+
self.defpw = defpw
20722077
self.grps = grps
20732078
self.iacct = {v: k for k, v in acct.items()}
20742079

@@ -2089,6 +2094,96 @@ def _reload(self) -> None:
20892094
MIMES[ext] = mime
20902095
EXTS.update({v: k for k, v in MIMES.items()})
20912096

2097+
def chpw(self, broker: Optional["BrokerCli"], uname, pw) -> tuple[bool, str]:
2098+
if not self.args.chpw:
2099+
return False, "feature disabled in server config"
2100+
2101+
if uname == "*" or uname not in self.defpw:
2102+
return False, "not logged in"
2103+
2104+
if len(pw) < self.args.chpw_len:
2105+
t = "minimum password length: %d characters"
2106+
return False, t % (self.args.chpw_len,)
2107+
2108+
hpw = self.ah.hash(pw) if self.ah.on else pw
2109+
if hpw in self.iacct:
2110+
return False, "password is taken"
2111+
2112+
with self.mutex:
2113+
ap = self.args.chpw_db
2114+
if not bos.path.exists(ap):
2115+
pwdb = {}
2116+
else:
2117+
with open(ap, "r", encoding="utf-8") as f:
2118+
pwdb = json.load(f)
2119+
2120+
pwdb = [x for x in pwdb if x[0] != uname]
2121+
pwdb.append((uname, self.defpw[uname], hpw))
2122+
2123+
with open(ap, "w", encoding="utf-8") as f:
2124+
json.dump(pwdb, f, separators=(",\n", ": "))
2125+
2126+
self.log("reinitializing due to password-change for user [%s]" % (uname,))
2127+
2128+
if not broker:
2129+
# only true for tests
2130+
self._reload()
2131+
return True, "new password OK"
2132+
2133+
broker.ask("_reload_blocking", False, False).get()
2134+
return True, "new password OK"
2135+
2136+
def setup_chpw(self, acct: dict[str, str]) -> None:
2137+
ap = self.args.chpw_db
2138+
if not self.args.chpw or not bos.path.exists(ap):
2139+
return
2140+
2141+
with open(ap, "r", encoding="utf-8") as f:
2142+
pwdb = json.load(f)
2143+
2144+
u404 = set()
2145+
urst = set()
2146+
uok = set()
2147+
for usr, orig, mod in pwdb:
2148+
if usr not in acct:
2149+
u404.add(usr)
2150+
continue
2151+
if acct[usr] != orig:
2152+
urst.add(usr)
2153+
continue
2154+
uok.add(usr)
2155+
acct[usr] = mod
2156+
2157+
if self.args.chpw_q:
2158+
return
2159+
2160+
for zs in uok:
2161+
urst.discard(zs)
2162+
2163+
if not self.args.chpw_v:
2164+
t = "chpw: %d loaded, %d default, %d ignored"
2165+
self.log(t % (len(uok), len(urst), len(u404)))
2166+
return
2167+
2168+
msg = ""
2169+
if uok:
2170+
t = "\033[0mloaded: \033[32m%s"
2171+
msg += t % (", ".join(list(uok)),)
2172+
if urst:
2173+
t = "%s\033[0mdefault: \033[35m%s"
2174+
msg += t % (
2175+
", " if msg else "",
2176+
", ".join(list(urst)),
2177+
)
2178+
if u404:
2179+
t = "%s\033[0mignored: \033[35m%s"
2180+
msg += t % (
2181+
", " if msg else "",
2182+
", ".join(list(u404)),
2183+
)
2184+
2185+
self.log("chpw: " + msg, 6)
2186+
20922187
def setup_pwhash(self, acct: dict[str, str]) -> None:
20932188
self.ah = PWHash(self.args)
20942189
if not self.ah.on:

copyparty/httpcli.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,9 @@ def handle_post_multipart(self) -> bool:
20892089
if act == "zip":
20902090
return self.handle_zip_post()
20912091

2092+
if act == "chpw":
2093+
return self.handle_chpw()
2094+
20922095
raise Pebkac(422, 'invalid action "{}"'.format(act))
20932096

20942097
def handle_zip_post(self) -> bool:
@@ -2393,6 +2396,22 @@ def handle_post_binary(self) -> bool:
23932396
self.reply(b"thank")
23942397
return True
23952398

2399+
def handle_chpw(self) -> bool:
2400+
assert self.parser
2401+
pwd = self.parser.require("pw", 64)
2402+
self.parser.drop()
2403+
2404+
ok, msg = self.asrv.chpw(self.conn.hsrv.broker, self.uname, pwd)
2405+
if ok:
2406+
ok, msg = self.get_pwd_cookie(pwd)
2407+
if ok:
2408+
msg = "new password OK"
2409+
2410+
redir = "/?h" if ok else ""
2411+
html = self.j2s("msg", h1=msg, h2='<a href="/?h">ack</a>', redir=redir)
2412+
self.reply(html.encode("utf-8"))
2413+
return True
2414+
23962415
def handle_login(self) -> bool:
23972416
assert self.parser
23982417
pwd = self.parser.require("cppwd", 64)
@@ -2417,12 +2436,12 @@ def handle_login(self) -> bool:
24172436
dst += "&" if "?" in dst else "?"
24182437
dst += "_=1#" + html_escape(uhash, True, True)
24192438

2420-
msg = self.get_pwd_cookie(pwd)
2439+
_, msg = self.get_pwd_cookie(pwd)
24212440
html = self.j2s("msg", h1=msg, h2='<a href="' + dst + '">ack</a>', redir=dst)
24222441
self.reply(html.encode("utf-8"))
24232442
return True
24242443

2425-
def get_pwd_cookie(self, pwd: str) -> str:
2444+
def get_pwd_cookie(self, pwd: str) -> tuple[bool, str]:
24262445
hpwd = self.asrv.ah.hash(pwd)
24272446
uname = self.asrv.iacct.get(hpwd)
24282447
if uname:
@@ -2454,7 +2473,7 @@ def get_pwd_cookie(self, pwd: str) -> str:
24542473
ck = gencookie(k, pwd, self.args.R, self.is_https, dur, "; HttpOnly")
24552474
self.out_headerlist.append(("Set-Cookie", ck))
24562475

2457-
return msg
2476+
return dur > 0, msg
24582477

24592478
def handle_mkdir(self) -> bool:
24602479
assert self.parser
@@ -3948,6 +3967,7 @@ def tx_mounts(self) -> bool:
39483967
k304=self.k304(),
39493968
k304vis=self.args.k304 > 0,
39503969
ver=S_VERSION if self.args.ver else "",
3970+
chpw=self.args.chpw and self.uname != "*",
39513971
ahttps="" if self.is_https else "https://" + self.host + self.req,
39523972
)
39533973
self.reply(html.encode("utf-8"))

copyparty/svchub.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ def __init__(
208208
t = "WARNING: --s-rd-sz (%d) is larger than --iobuf (%d); this may lead to reduced performance"
209209
self.log("root", t % (args.s_rd_sz, args.iobuf), 3)
210210

211+
if args.chpw and args.idp_h_usr:
212+
t = "ERROR: user-changeable passwords is incompatible with IdP/identity-providers; you must disable either --chpw or --idp-h-usr"
213+
self.log("root", t, 1)
214+
raise Exception(t)
215+
211216
bri = "zy"[args.theme % 2 :][:1]
212217
ch = "abcdefghijklmnopqrstuvwx"[int(args.theme / 2)]
213218
args.theme = "{0}{1} {0} {1}".format(ch, bri)
@@ -815,18 +820,21 @@ def reload(self) -> str:
815820
Daemon(self._reload, "reloading")
816821
return "reload initiated"
817822

818-
def _reload(self, rescan_all_vols: bool = True) -> None:
823+
def _reload(self, rescan_all_vols: bool = True, up2k: bool = True) -> None:
819824
with self.up2k.mutex:
820825
if self.reloading != 1:
821826
return
822827
self.reloading = 2
823828
self.log("root", "reloading config")
824829
self.asrv.reload()
825-
self.up2k.reload(rescan_all_vols)
830+
if up2k:
831+
self.up2k.reload(rescan_all_vols)
832+
else:
833+
self.log("root", "reload done")
826834
self.broker.reload()
827835
self.reloading = 0
828836

829-
def _reload_blocking(self, rescan_all_vols: bool = True) -> None:
837+
def _reload_blocking(self, rescan_all_vols: bool = True, up2k: bool = True) -> None:
830838
while True:
831839
with self.up2k.mutex:
832840
if self.reloading < 2:
@@ -837,7 +845,7 @@ def _reload_blocking(self, rescan_all_vols: bool = True) -> None:
837845
# try to handle multiple pending IdP reloads at once:
838846
time.sleep(0.2)
839847

840-
self._reload(rescan_all_vols=rescan_all_vols)
848+
self._reload(rescan_all_vols=rescan_all_vols, up2k=up2k)
841849

842850
def stop_thr(self) -> None:
843851
while not self.stop_req:

copyparty/web/splash.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,15 @@ html.z a.g {
182182
border-color: #af4;
183183
box-shadow: 0 .3em 1em #7d0;
184184
}
185+
#x,
185186
input {
186187
color: #a50;
187188
background: #fff;
188189
border: 1px solid #a50;
189-
border-radius: .5em;
190-
padding: .5em .7em;
191-
margin: 0 .5em 0 0;
190+
border-radius: .3em;
191+
padding: .3em .6em;
192+
margin: 0 .3em 0 0;
193+
font-size: 1em;
192194
}
193195
input::placeholder {
194196
font-size: 1.2em;
@@ -197,6 +199,7 @@ input::placeholder {
197199
opacity: 0.64;
198200
color: #930;
199201
}
202+
#x,
200203
html.z input {
201204
color: #fff;
202205
background: #626;

copyparty/web/splash.html

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,14 @@ <h1 id="cc">client config:</h1>
9292

9393
<h1 id="l">login for more:</h1>
9494
<div>
95-
<form method="post" enctype="multipart/form-data" action="{{ r }}/{{ qvpath }}">
96-
<input type="hidden" name="act" value="login" />
97-
<input type="password" name="cppwd" placeholder=" password" />
95+
<form id="lf" method="post" enctype="multipart/form-data" action="{{ r }}/{{ qvpath }}">
96+
<input type="hidden" id="la" name="act" value="login" />
97+
<input type="password" id="lp" name="cppwd" placeholder=" password" />
9898
<input type="hidden" name="uhash" id="uhash" value="x" />
99-
<input type="submit" value="Login" />
99+
<input type="submit" id="ls" value="Login" />
100+
{% if chpw %}
101+
<a id="x" href="#">change password</a>
102+
{% endif %}
100103
{% if ahttps %}
101104
<a id="w" href="{{ ahttps }}">switch to https</a>
102105
{% endif %}

0 commit comments

Comments
 (0)