3 # Copyright 2021 - 2024 Paul Hänsch
5 # Permission to use, copy, modify, and/or distribute this software for any
6 # purpose with or without fee is hereby granted, provided that the above
7 # copyright notice and this permission notice appear in all copies.
9 # THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
12 # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
15 # IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17 [ -n "$include_users" ] && return 0
20 . "${_EXEC:-.}/cgilite/session.sh"
21 . "${_EXEC:-.}/cgilite/storage.sh"
23 SENDMAIL=${SENDMAIL-sendmail}
25 USER_REGISTRATION="${USER_REGISTRATION-true}"
26 USER_REQUIREEMAIL="${USER_REQUIREEMAIL-true}"
27 USER_ACCOUNTPAGE="${USER_ACCOUNTPAGE}"
29 USER_ACCOUNTEXPIRE="${USER_ACCOUNTEXPIRE:-$((86400 * 730))}"
30 USER_CONFIRMEXPIRE="${USER_CONFIRMEXPIRE:-86400}"
32 HTTP_HOST="$(HEADER Host)"
33 MAILFROM="noreply@${HTTP_HOST%:*}"
35 [ "$HTTPS" ] && SCHEMA=https || SCHEMA=http
38 # UID UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
39 # (pending|active|deleted)
43 USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
44 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
48 USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
49 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
53 # override all functions marked with "TRANSLATION"
54 # sed -n '/TRANSLATION$/,/^}/p;' <cgilite/users.sh
59 user_db="${user_db:-${_DATA}/users.db}"
65 USER_ID='' USER_NAME='' USER_STATUS='' USER_EMAIL='' USER_PWSALT=''
66 USER_PWHASH='' USER_EXPIRE='' USER_DEVICES='' USER_FUTUREUSE=''
69 read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
70 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
71 elif [ "$user" -a -f "$user_db" -a -r "$user_db" ]; then
72 read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
73 USER_EXPIRE USER_DEVICES USER_FUTUREUSE <<-EOF
74 $(grep "^${user} " "${user_db}")
77 if [ "$USER_ID" -a "${USER_EXPIRE:-0}" -gt "$_DATE" ]; then
78 USER_NAME="$(UNSTRING "$USER_NAME")"
79 USER_EMAIL="$(UNSTRING "$USER_EMAIL")"
80 USER_DEVICES="$(UNSTRING "$USER_DEVICES")"
81 unset USER_PWSALT USER_PWHASH
89 # internal function for user update
90 local uid="$1" uname status email pwsalt pwhash expire devices futureuse
91 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
94 for arg in "$@"; do case $arg in
95 uname=*) uname="${arg#*=}";;
96 status=*) status="${arg#*=}";;
97 email=*) email="${arg#*=}";;
98 password=*) pwsalt="$(randomid)"; pwhash="$(user_pwhash "$pwsalt" "${arg#*=}")";;
99 expire=*) expire="${arg#*=}";;
100 devices=*) devices="${arg#*=}";;
103 if LOCK "$user_db"; then
104 while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES \
106 if [ "$UID_" = "$uid" ]; then
107 printf '%s %s %s %s %s %s %i %s %s\n' \
108 "$uid" "$(STRING "${uname-$(UNSTRING "$UNAME")}")" \
109 "${status:-${status-${STATUS}}${status+\\}}" \
110 "${email:-${email-${EMAIL}}${email+\\}}" \
111 "${pwsalt:-${PWSALT}}" "${pwhash:-${PWHASH}}" \
112 "${expire:-$((_DATE + USER_ACCOUNTEXPIRE))}" \
113 "$(STRING "${devices-$(UNSTRING "$DEVICES")}")" \
115 elif [ "$STATUS" = pending -a ! "$EXPIRE" -ge "$_DATE" ]; then
116 # omit expired invitations from output
119 printf '%s %s %s %s %s %s %i %s %s\n' \
120 "$UID_" "$UNAME" "$STATUS" "$EMAIL" "$PWSALT" "$PWHASH" \
121 "$EXPIRE" "$DEVICES" "$FUTUREUSE"
123 done <"$user_db" >"${user_db}.$$"
124 mv -- "${user_db}.$$" "$user_db"
132 local user="${1:-$(timeid)}"
135 if LOCK "$user_db"; then
136 if grep -q "^${user} " "$user_db"; then
140 printf '%s \\ %s \\ \\ \\ %i \\ \\\n' \
141 "$user" "pending" "$(( _DATE + USER_CONFIRMEXPIRE ))" >>"$user_db"
146 if [ $# -eq 0 ]; then
149 elif update_user "$user" "$@"; then
161 if [ ! "$USER_IDMAP" ]; then
163 USER_IDMAP="${USER_IDMAP}${USER_ID} ${USER_NAME}${BR}"
166 if [ "$uid" -a "$USER_IDMAP" != "${USER_IDMAP##*${uid} }" ]; then
167 ret="${USER_IDMAP##*${uid} }"; ret="${ret%%${BR}*}";
170 elif [ "$uid" ]; then
173 printf '%s' "$USER_IDMAP"
179 local name="$(STRING "$1")" ret
180 [ "$USER_IDMAP" ] || user_idmap >/dev/null
182 if [ "${name%\\}" -a "$USER_IDMAP" != "${USER_IDMAP% ${name}${BR}*}" ]; then
183 ret="${USER_IDMAP% ${name}${BR}*}"; ret="${ret##*${BR}}"
192 { [ $# -gt 0 ] && printf %s "$*" || cat; } \
198 /^[a-zA-Z][a-zA-Z0-9 -~]{2,127}$/!d;
204 { [ $# -gt 0 ] && printf %s "$*" || cat; } \
206 # W3C recommended email regex
207 # https://html.spec.whatwg.org/multipage/input.html#email-state-(type=email)
208 /^[a-zA-Z0-9.!#$%&'\''*+\/=?^_`{|}~-]+@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/p;
213 local uname="$(STRING "$1")"
214 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
215 [ -f "$user_db" -a -r "$user_db" ] \
216 && while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE; do
217 [ "$EXPIRE" -gt "$_DATE" -a "$UNAME" = "$uname" ] && return 0
223 local email="$(STRING "$1")"
224 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
225 [ -f "$user_db" -a -r "$user_db" ] \
226 && while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE; do
227 [ "$EXPIRE" -gt "$_DATE" -a "$EMAIL" = "$email" ] && return 0
233 local salt="$1" secret="$2" hash
234 hash="$(printf '%s\n%s\n' "$secret" "$salt" |sha256sum)"
235 printf '%s\n' "${hash%% *}"
238 user_register_email() { # TRANSLATION
239 "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
242 Subject: Your account registration at ${HTTP_HOST%:*}
244 Someone tried to sign up for a user account using this email address.
246 You can activate your account using this link:
248 ${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
250 This registration link will expire after $((USER_CONFIRMEXPIRE / 3600)) hours.
252 If you did not request an account at ${HTTP_HOST%:*}, then someone else
253 probably entered your email address by accident. In this case you shoud
254 simply ignore this message and we will remove your email address from
255 our database within the next day.
257 This is an automatic email. Any direct reply will not be received.
258 Your Account Registration Robot.
263 # reserve account, send registration mail
264 # preliminary uid, expiration, signature
265 local uid="$(timeid)"
266 local uname="$(POST uname |user_checkname)"
267 local email="$(POST email |user_checkemail)"
268 local pwsalt="$(randomid)"
269 local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
271 if [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
272 REDIRECT "${_BASE}${PATH_INFO}#ERROR_REGISTRATION_DISABLED"
275 if [ "$USER_REQUIREEMAIL" = true ]; then
276 if [ ! "$email" ]; then
277 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
278 elif user_emailexist "$email"; then
279 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
280 elif new_user "$uid" status=pending email="$email" expire="$((_DATE + USER_CONFIRMEXPIRE))"; then
281 debug "Sending Activation Link:" \
282 "${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
284 REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
286 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
289 elif [ "$USER_REQUIREEMAIL" != true ]; then
290 if [ ! "$uname" ]; then
291 REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_INVALID"
292 elif user_nameexist "$uname"; then
293 REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_EXISTS"
294 elif [ ! "$pw" ]; then
295 REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_EMPTYTOOSHORT"
296 elif [ "$pw" != "$pwconfirm" ]; then
297 REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_MISMATCH"
298 elif new_user "$uid" uname="$uname" status=active email="$email" password="$pw" expire="$((_DATE + USER_ACCOUNTEXPIRE))"; then
300 SESSION_BIND user_id "$uid"
302 if [ "$USER_ACCOUNTPAGE" ]; then
303 REDIRECT "${USER_ACCOUNTPAGE}"
305 REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
308 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
313 user_invite_email(){ # TRANSLATION
314 "$SENDMAIL" -t -f "$MAILFROM" <<-EOF
317 Subject: You have been invited to ${HTTP_HOST%:*}
319 ${USER_NAME:-Someone} has offered an invitation to this email address.
323 You can create your account using this link:
325 ${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
327 This registration link will expire after $((USER_CONFIRMEXPIRE / 3600)) hours.
329 If you do not know what this is about, then someone else probably
330 entered your email address by accident. In this case you shoud
331 simply ignore this message and we will remove your email address from
332 our database within the next day.
334 This is an automatic email. Any direct reply will not be received.
335 Your Account Registration Robot.
340 local uid="$(timeid)"
341 local email="$(POST email |user_checkemail)"
342 local message="$(POST message)"
344 if [ ! "$email" ]; then
345 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
346 elif user_emailexist "$email"; then
347 REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
348 elif new_user "$uid" status=pending email="$email" expire="$((_DATE + USER_CONFIRMEXPIRE))"; then
349 debug "Sending Invitation Link:" \
350 "${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
352 REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
354 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
361 local uid="$(POST uid |checkid || printf invalid)"
362 local signature="$(POST signature)"
363 local uname="$(POST uname |user_checkname)"
364 local pwsalt="$(randomid)"
365 local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
369 if [ "$signature" != "$(session_mac "$uid")" ]; then
370 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
371 elif [ ! "$uname" ]; then
372 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_INVALID"
373 elif user_nameexist "$uname"; then
374 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_EXISTS"
375 elif [ ! "$pw" ]; then
376 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_EMPTYTOOSHORT"
377 elif [ "$pw" != "$pwconfirm" ]; then
378 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_MISMATCH"
379 elif [ "$USER_STATUS" != pending -o \! "$USER_EXPIRE" -gt "$_DATE" ]; then
380 REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
381 elif update_user "$USER_ID" uname="$uname" status=active password="$pw"; then
383 SESSION_BIND user_id "$USER_ID"
384 if [ "$USER_ACCOUNTPAGE" ]; then
385 REDIRECT "${USER_ACCOUNTPAGE}"
387 REDIRECT "${_BASE}${PATH_INFO}?user_register=confirm#USER_REGISTER_CONFIRM"
390 REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
396 # keep logged in - device cookie?
397 # initialize new session!
398 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
399 local uname="$(POST uname |STRING)" pw="$(POST pw)"
401 [ -f "$user_db" -a -r "$user_db" ] \
402 && while read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE; do
403 if [ "$UNAME" = "$uname" -o "$EMAIL" = "$uname" ]; then
404 if [ "$STATUS" = active -a "$EXPIRE" -gt "$_DATE" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$pw")" ]; then
406 SESSION_BIND user_id "$UID_"
407 REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_IN"
411 REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_LOGIN"
415 # destroy cookie, destroy session
419 SET_COOKIE 0 user_id="" Path="/${_BASE#/}" SameSite=Strict HttpOnly
420 REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_OUT"
424 # todo: username update, email update / email confirm
425 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
426 # local uname="$(POST uname |STRING)"
427 local uid oldpw pw pwconfirm
430 oldpw="$(POST oldpw)"
431 pw="$(POST pw |grep -m1 -xE '.{6,}')"
432 pwconfirm="$(POST pwconfirm)"
435 read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE <<-EOF
436 $(grep "^${uid} " "$user_db")
439 if [ "$UID_" = "$USER_ID" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$oldpw")" ]; then
440 if [ "$pw" -a "$pw" = "$pwconfirm" ]; then
441 update_user "${uid}" password="$pw"
442 REDIRECT "${_BASE}${PATH_INFO}#UPDATE_SUCCESS"
444 REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_MISMATCH"
446 elif [ "$UID_" = "$USER_ID" ]; then
447 REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_AUTH_PASSWORD"
449 REDIRECT "${_BASE}${PATH_INFO}#ERROR_NOTLOGGEDIN"
461 read_user "$(SESSION_VAR user_id)"
462 [ "$USER_STATUS" -a "$USER_STATUS" != active ] && eval $UNSET_USER
464 [ "$REQUEST_METHOD" = POST ] && case "$(POST action)" in
465 user_register) user_register ;;
466 user_confirm) user_confirm ;;
467 user_invite) user_invite ;;
468 user_login) user_login ;;
469 user_logout) user_logout ;;
470 user_update) user_update ;;
477 export USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
478 USER_EXPIRE USER_DEVICES USER_FUTUREUSE
482 if [ ! "$USER_ID" ]; then
484 [div #user_update .nouser
485 This page can only be used by registered users
490 [form #user_update method=POST
491 [hidden "uid" "$USER_ID"]
492 [p .username Logged in as $USER_NAME]
493 [input type=password name=oldpw placeholder="Current Passphrase"]
494 [input type=password name=pw placeholder="New Passphrase" pattern=".{6,}"]
495 [input type=password name=pwconfirm placeholder="Confirm New Passphrase" pattern=".{6,}"]
496 [submit "action" "user_update" Update Passphrase]
502 w_user_register_disabled(){ # TRANSLATION
504 [div #user_register .disabled
505 User Registration is disabled.
509 w_user_register_sendmail(){ # TRANSLATION
511 [form #user_register .registeremail method=POST
512 [p We will send an activation mail to your email address.
513 You can continue the signup process when you click on the
514 activation link in this email.]
515 [input type=email name=email placeholder="Email"]
516 [submit "action" "user_register" Sign Up]
520 w_user_register_direct(){ # TRANSLATION
522 [form #user_register .registername method=POST
523 [input name=uname placeholder="Choose Username" tooltip="Your username may contain any character but the @ sign. It must be at least 3 characters long, and it must start with a letter." pattern="^\[\\\\p{L}\]\[\\\\p{L}0-9 -~\]{2,127}$" autocomplete=off]
524 [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
525 [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
526 [submit "action" "user_register" Sign Up]
532 if [ "$(GET user_confirm)" ]; then
534 elif [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
535 w_user_register_disabled
536 elif [ "$USER_REQUIREEMAIL" = true ]; then
537 w_user_register_sendmail
538 elif [ "$USER_REQUIREEMAIL" != true ]; then
539 w_user_register_direct
543 w_user_confirm_proceed(){ # TRANSLATION
545 [form #user_confirm method=POST
546 [input type=hidden name=uid value="${uid}"]
547 [input type=hidden name=signature value="${signature}"]
548 $([ "$EMAIL" != '\' ] && printf \
549 '[input disabled=disabled value="%s" placeholder="Email"]' "$(UNSTRING "$EMAIL" |HTML)"
551 [input name=uname placeholder="Choose Username" tooltip="Your username may contain any character but the @ sign. It must be at least 3 characters long, and it must start with a letter." pattern="^\[\\\\p{L}\]\[\\\\p{L}0-9 -~\]{2,127}$" autocomplete=off]
552 [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
553 [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
554 [submit "action" "user_confirm" Finish Registration]
558 w_user_confirm_expired(){ # TRANSLATION
560 [div #user_confirm .expired
561 [p This activation link is not valid anymore.]
565 w_user_confirm_invalid(){ # TRANSLATION
567 [div #user_confirm .invalid
568 [p This activation link is invalid. Make sure you copied the whole activation link from your email and be careful not to include any line breaks.]
574 local UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
575 local user_confirm="$(GET user_confirm)"
576 local uid="${user_confirm% *}" signature="${user_confirm#* }"
578 if [ "$signature" = "$(session_mac "$uid")" ]; then
579 read -r UID_ UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE <<-EOF
580 $(grep "^${uid} " "$user_db")
582 if [ "$STATUS" = pending -a "$EXPIRE" -gt "$_DATE" ]; then
583 w_user_confirm_proceed
585 w_user_confirm_expired
588 w_user_confirm_invalid
592 w_user_invite_email(){ # TRANSLATION
594 [form #user_invite method=POST
595 [input placeholder="Email Recipient" name=email autocomplete=off]
596 [textarea name="message" placeholder="Message to recipient" . ]
597 [submit "action" "user_invite" Send Invitation]
601 w_user_invite_link(){ # TRANSLATION
603 [div #user_invite .link
604 [p An anonymous user account has been set up. Send the following link to the intended user, so they may claim their account. The link will remain valid for $((USER_CONFIRMEXPIRE / 3600)) hours.]
605 [a href="$(HTML "$invlink")" . $(HTML "$invlink")]
607 [p [a href="#" . Set up another account]]
611 w_user_invite_deny(){ # TRANSLATION
613 [div #user_invite .notallowed
614 Only registered users may send an invitation to another user.
622 if [ "$(GET user_confirm)" ]; then
624 elif [ "$USER_ID" -a "$USER_REQUIREEMAIL" = true ]; then
626 elif [ "$USER_ID" ]; then
628 new_user "$uid" status=pending expire="$((_DATE + USER_CONFIRMEXPIRE))"
629 invlink="${SCHEMA}://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
630 debug "New Invitation Link: $invlink"
637 w_user_login_logon(){ # TRANSLATION
639 [form #user_login .login method=POST
640 [input name=uname placeholder="Username or Email"]
641 [input type=password name=pw placeholder="Passphrase"]
642 [submit "action" "user_login" Login]
646 w_user_login_logoff(){ # TRANSLATION
648 [form #user_login .logout method=POST
649 [p Logged in as [span . $(HTML ${USER_NAME})]]
650 [submit "action" "user_logout" Logout]
656 if [ ! "$USER_ID" ]; then
658 elif [ "$USER_ID" ]; then