]> git.plutz.net Git - cgilite/blob - users.sh
idmap functions
[cgilite] / users.sh
1 #!/bin/sh
2
3 [ -n "$include_users" ] && return 0
4 include_users="$0"
5
6 . "${_EXEC}/cgilite/session.sh"
7 . "${_EXEC}/cgilite/storage.sh"
8
9 USER_REGISTRATION="${USER_REGISTRATION:-true}"
10 USER_REQUIREEMAIL="${USER_REQUIREEMAIL:-true}"
11
12 HTTP_HOST="$(HEADER Host)"
13 MAILFROM="${MAILDOMAIN:-noreply@${HTTP_HOST%:*}}"
14
15 # == FILE FORMAT ==
16 # UID   UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
17 #               (pending|active|deleted)
18
19 # == GLOBALS ==
20 UNSET_USER='unset \
21   USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
22   USER_EXPIRE USER_DEVICES USER_FUTUREUSE
23 '
24
25 LOCAL_USER='local \
26   USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
27   USER_EXPIRE USER_DEVICES USER_FUTUREUSE
28 '
29
30 unset USER_IDMAP
31 eval "$UNSET_USER"
32
33 user_db="${user_db:-${_DATA}/users.db}"
34
35 read_user() {
36   local user="$1"
37
38   # Global exports
39   USER_ID='' USER_NAME='' USER_STATUS='' USER_EMAIL='' USER_PWSALT=''
40   USER_PWHASH='' USER_EXPIRE='' USER_DEVICES='' USER_FUTUREUSE=''
41
42   if [ $# -eq 0 ]; then
43     read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
44             USER_EXPIRE USER_DEVICES USER_FUTUREUSE
45   elif [ "$user" -a -f "$user_db" -a -r "$user_db" ]; then
46     read -r USER_ID USER_NAME USER_STATUS USER_EMAIL USER_PWSALT USER_PWHASH \
47             USER_EXPIRE USER_DEVICES USER_FUTUREUSE <<-EOF
48         $(grep "^${user}        " "${user_db}")
49         EOF
50   fi
51   if [ "$USER_ID" -a "${USER_EXPIRE:-0}" -gt "$_DATE" ]; then
52        USER_NAME="$(UNSTRING "$USER_NAME")"
53       USER_EMAIL="$(UNSTRING "$USER_EMAIL")"
54     USER_DEVICES="$(UNSTRING "$USER_DEVICES")"
55     unset USER_PWSALT USER_PWHASH
56   else
57     eval "$UNSET_USER"
58     return 1
59   fi
60 }
61
62 update_user() {
63   # internal function for user update
64   local uid="$1" uname status email pwsalt pwhash expire devices futureuse
65   local UID UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES FUTUREUSE
66   local arg
67
68   for arg in "$@"; do case $arg in
69     uname=*) uname="${arg#*=}";;
70     status=*) status="${arg#*=}";;
71     email=*) email="${arg#*=}";;
72     password=*) pwsalt="$(randomid)"; pwhash="$(user_pwhash "$pwsalt" "${arg#*=}")";;
73     expire=*) expire="${arg#*=}";;
74     devices=*) devices="${arg#*=}";;
75   esac; done
76
77   if LOCK "$user_db"; then
78     while read -r UID UNAME STATUS EMAIL PWSALT PWHASH EXPIRE DEVICES \
79                   FUTUREUSE; do
80     if [ "$UID" = "$uid" ]; then
81       printf '%s        %s      %s      %s      %s      %s      %i      %s      %s\n' \
82              "$uid" "$(STRING "${uname-$(UNSTRING "$UNAME")}")" \
83              "${status:-${status-${STATUS}}${status+\\}}" \
84              "${email:-${email-${EMAIL}}${email+\\}}" \
85              "${pwsalt:-${PWSALT}}" "${pwhash:-${PWHASH}}" \
86              "${expire:-$((_DATE + 86400 * 730))}" \
87              "$(STRING "${devices-$(UNSTRING "$DEVICES")}")" \
88              "${FUTUREUSE:-\\}"
89     elif [ "$STATUS" = pending -a ! "$EXPIRE" -ge "$_DATE" ]; then
90       # omit expired invitations from output
91       :
92     else
93       printf '%s        %s      %s      %s      %s      %s      %i      %s      %s\n' \
94              "$UID" "$UNAME" "$STATUS" "$EMAIL" "$PWSALT" "$PWHASH" \
95              "$EXPIRE" "$DEVICES" "$FUTUREUSE"
96     fi
97     done <"$user_db" >"${user_db}.$$"
98     mv -- "${user_db}.$$" "$user_db"
99     RELEASE "$user_db"
100   else
101     return 1
102   fi
103 }
104
105 new_user(){
106   local user="${1:-$(timeid)}"
107   shift 1
108
109   if LOCK "$user_db"; then
110     if grep -q "^${user}        " "$user_db"; then
111       RELEASE "$user_db"
112       return 1
113     fi
114     printf '%s  \\      %s      \\      \\      \\      %i      \\      \\\n' \
115            "$user" "pending" "$(( $_DATE + 86400 ))" >>"$user_db"
116   else
117     return 1
118   fi
119
120   if [ $# -eq 0 ]; then
121     RELEASE "$user_db"
122     return 0
123   elif update_user "$user" "$@"; then
124     return 0
125   else
126     RELEASE "$user_db"
127     return 1
128   fi
129 }
130
131 user_idmap(){
132   local uid="$1" ret
133   eval "$LOCAL_USER"
134
135   if [ ! "$USER_IDMAP" ]; then
136     while read_user; do
137       USER_IDMAP="${USER_IDMAP}${USER_ID}       ${USER_NAME}${BR}"
138     done <"$user_db"
139   fi
140   if [ "$uid" -a "$USER_IDMAP" != "${USER_IDMAP##*${uid}        }" ]; then
141     ret="${USER_IDMAP##*${uid}  }"; ret="${ret%%${BR}*}";
142     printf '%s\n' "$ret"
143     return 0
144   elif [ "$uid" ]; then
145     return 1
146   else
147     printf '%s' "$USER_IDMAP"
148     return 0
149   fi
150 }
151
152 user_idof(){
153   local name="$(STRING "$1")" ret
154   [ "$USER_IDMAP" ] || user_idmap >/dev/null
155
156   if [ "${name%\\}" -a "$USER_IDMAP" != "${USER_IDMAP%  ${name}${BR}*}" ]; then
157     ret="${USER_IDMAP%  ${name}${BR}*}"; ret="${ret##*${BR}}"
158     printf '%s\n' "$ret"
159     return 0
160   else
161     return 1
162   fi
163 }
164
165 user_checkname(){
166   { [ $# -gt 0 ] && printf %s "$*" || cat; } \
167   | sed -nE '
168     :X; $!{N;bX;}
169     s;[ \t\r\n]+; ;g;
170     s;^ ;;; s; $;;;
171     /@/d;
172     /^[a-zA-Z][a-zA-Z0-9 -~]{2,127}$/!d;
173     p;
174     '
175 }
176
177 user_checkemail(){
178   { [ $# -gt 0 ] && printf %s "$*" || cat; } \
179   | sed -nE '
180     # W3C recommended email regex
181     # https://html.spec.whatwg.org/multipage/input.html#email-state-(type=email)
182     /^[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;
183     '
184 }
185
186 user_nameexist(){
187   local uname="$(STRING "$1")"
188   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
189   [ -f "$user_db" -a -r "$user_db" ] \
190   && while read -r UID  UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
191     [ "$EXPIRE" -gt "$_DATE" -a "$UNAME" = "$uname" ] && return 0
192   done <"$user_db"
193   return 1
194 }
195
196 user_emailexist(){
197   local email="$(STRING "$1")"
198   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
199   [ -f "$user_db" -a -r "$user_db" ] \
200   && while read -r UID  UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
201     [ "$EXPIRE" -gt "$_DATE" -a "$EMAIL" = "$email" ] && return 0
202   done <"$user_db"
203   return 1
204 }
205
206 user_pwhash(){
207   local salt="$1" secret="$2" hash
208   hash="$(printf '%s\n%s\n' "$secret" "$salt" |sha256sum)"
209   printf '%s\n' "${hash%% *}"
210 }
211
212 user_register(){
213   # reserve account, send registration mail
214   # preliminary uid, expiration, signature
215   local uid="$(timeid)"
216   local uname="$(POST uname |user_checkname)"
217   local email="$(POST email |user_checkemail)"
218   local pwsalt="$(randomid)"
219   local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
220
221   if [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
222     REDIRECT "${_BASE}${PATH_INFO}#ERROR_REGISTRATION_DISABLED"
223   fi
224
225   if   [ "$USER_REQUIREEMAIL" = true ]; then
226     if [ ! "email" ]; then
227       REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
228     elif user_emailexist "$email"; then
229       REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
230     elif new_user "$uid" status=pending email="$email" expire="$((_DATE + 86400))"; then
231       debug "Sending Activation Link:" \
232             "https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
233       sendmail -t -f "$MAILFROM" <<-EOF
234         From: ${MAILFROM}
235         To: ${email}
236         Subject: Your account registration at ${HTTP_HOST%:*}
237
238         Someone tried to sign up for a user account using this email address.
239
240         You can activate your account using this link:
241
242             https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
243
244         This registration link will expire after 24 hours.
245
246         If you did not request an account at ${HTTP_HOST%:*}, then someone else
247         probably entered your email address by accident. In this case you shoud
248         simply ignore this message and we will remove your email address from
249         our database within the next day.
250
251         This is an automatic email. Any direct reply will not be received.
252         Your Account Registration Robot.
253         EOF
254       REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
255     else
256       REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
257     fi
258
259   elif [ "$USER_REQUIREEMAIL" != true ]; then
260     if [ ! "$uname" ]; then
261       REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_INVALID"
262     elif user_nameexist "$uname"; then
263       REDIRECT "${_BASE}${PATH_INFO}#ERROR_UNAME_EXISTS"
264     elif [ ! "$pw" ]; then
265       REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_EMPTYTOOSHORT"
266     elif [ "$pw" != "$pwconfirm" ]; then
267       REDIRECT "${_BASE}${PATH_INFO}#ERROR_PW_MISMATCH"
268     elif new_user "$uid" uname="$uname" status=active email="$email" password="$pw" expire="$((_DATE + 86400 * 730))"; then
269       SESSION_COOKIE new
270       SESSION_BIND user_id "$uid"
271
272       REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
273     else
274       REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
275     fi
276   fi
277 }
278
279 user_invite(){
280   local uid="$(timeid)"
281   local email="$(POST email |user_checkemail)"
282   local message="$(POST message)"
283
284   if [ ! "email" ]; then
285     REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_INVALID"
286   elif user_emailexist "$email"; then
287     REDIRECT "${_BASE}${PATH_INFO}#ERROR_EMAIL_EXISTS"
288   elif new_user "$uid" status=pending email="$email" expire="$((_DATE + 86400))"; then
289     debug "Sending Invitation Link:" \
290           "https://${HTTP_HOST}${BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")"
291     sendmail -t -f "$MAILFROM" <<-EOF
292         From: ${MAILFROM}
293         To: ${email}
294         Subject: You have been invited to ${HTTP_HOST%:*}
295
296         ${USER_NAME:-Someone} has offered an invitation to this email address.
297
298         ${message}
299
300         You can create your account using this link:
301
302             https://${HTTP_HOST}${_BASE}${PATH_INFO}?user_confirm=${uid}+$(session_mac "$uid")
303
304         This registration link will expire after 24 hours.
305
306         If you do not know what this is about, then someone else probably
307         entered your email address by accident. In this case you shoud
308         simply ignore this message and we will remove your email address from
309         our database within the next day.
310
311         This is an automatic email. Any direct reply will not be received.
312         Your Account Registration Robot.
313         EOF
314     REDIRECT "${_BASE}${PATH_INFO}#USER_REGISTER_CONFIRM"
315   else
316     REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
317   fi
318 }
319
320 user_confirm(){
321   # enable account
322   eval "$LOCAL_USER"
323   local uid="$(POST uid |checkid || printf invalid)"
324   local signature="$(POST signature)"
325   local uname="$(POST uname |user_checkname)"
326   local pwsalt="$(randomid)"
327   local pw="$(POST pw |grep -m1 -xE '.{6,}' )" pwconfirm="$(POST pwconfirm)"
328
329   read_user "${uid}"
330
331   if [ "$signature" != "$(session_mac "$uid")" ]; then
332     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
333   elif [ ! "$uname" ]; then
334     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_INVALID"
335   elif user_nameexist "$uname"; then
336     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_UNAME_EXISTS"
337   elif [ ! "$pw" ]; then
338     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_EMPTYTOOSHORT"
339   elif [ "$pw" != "$pwconfirm" ]; then
340     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_PW_MISMATCH"
341   elif [ "$USER_STATUS" != pending -o \! "$USER_EXPIRE" -gt "$_DATE" ]; then
342     REDIRECT "${_BASE}${PATH_INFO}?${QUERY_STRING}#ERROR_LINK_INVALID"
343   elif update_user "$USER_ID" uname="$uname" status=active password="$pw"; then
344     SESSION_COOKIE new
345     SESSION_BIND user_id "$USER_ID"
346     REDIRECT "${_BASE}${PATH_INFO}?user_register=confirm#USER_REGISTER_CONFIRM"
347   else
348     REDIRECT "${_BASE}${PATH_INFO}#ERROR_USER_NOLOCK"
349   fi
350 }
351
352 user_login(){
353   # set cookie
354   # keep logged in - device cookie?
355   # initialize new session!
356   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
357   local uname="$(POST uname |STRING)" pw="$(POST pw)"
358
359   [ -f "$user_db" -a -r "$user_db" ] \
360   && while read -r UID  UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE; do
361     if [ "$UNAME" = "$uname" -o "$EMAIL" = "$uname" ]; then
362       if [ "$STATUS" = active -a "$EXPIRE" -gt "$_DATE" -a "$PWHASH" = "$(user_pwhash "$PWSALT" "$pw")" ]; then
363         SESSION_COOKIE new
364         SESSION_BIND user_id "$UID"
365         REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_IN"
366       fi
367     fi
368   done <"$user_db"
369   REDIRECT "${_BASE}${PATH_INFO}#ERROR_INVALID_LOGIN"
370 }
371
372 user_logout(){
373   # destroy cookie, destroy session
374   # keep device cookie
375   new_session
376   SESSION_COOKIE new
377   SET_COOKIE 0 user_id="" Path="/${_BASE#/}" SameSite=Strict HttpOnly
378   REDIRECT "${_BASE}${PATH_INFO}#USER_LOGGED_OUT"
379 }
380
381 user_update(){
382   # passphrase, email
383   :
384 }
385 user_recover(){
386   # send recover link
387   :
388 }
389 user_disable(){
390   :
391 }
392
393 read_user "$(SESSION_VAR user_id)"
394 [ "$USER_STATUS" -a "$USER_STATUS" != active ] && eval $UNSET_USER
395
396 [ "$REQUEST_METHOD" = POST ] && case "$(POST action)" in
397   user_register) user_register ;;
398   user_confirm)  user_confirm ;;
399   user_invite)   user_invite ;;
400   user_login)    user_login ;;
401   user_logout)   user_logout ;;
402   user_update)
403     :;;
404   user_recover)
405     :;;
406   user_disable)
407     :;;
408 esac
409
410 w_user_register(){
411   if [ "$(GET user_confirm)" ]; then
412     w_user_confirm
413   elif [ "$USER_REGISTRATION" != true -a -s "$user_db" ]; then
414     cat <<-EOF
415         [div #user_register .disabled
416         User Registration is disabled.
417         ]
418         EOF
419   elif [ "$USER_REQUIREEMAIL" = true ]; then
420     cat <<-EOF
421         [form #user_register .registeremail method=POST
422           [p We will send an activation mail to your email address.
423             You can continue the signup process when you click on the
424             activation link in this email.]
425           [input type=email name=email placeholder="Email"]
426           [submit "action" "user_register" Sign Up]
427         ]
428         EOF
429   elif [ "$USER_REQUIREEMAIL" != true ]; then
430     cat <<-EOF
431         [form #user_register .registername method=POST
432           [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="^\[a-zA-Z\]\[a-zA-Z0-9 -~\]{2,127}$" autocomplete=off]
433           [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
434           [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
435           [submit "action" "user_register" Sign Up]
436         ]
437         EOF
438   fi
439 }
440
441 w_user_confirm(){
442   local UID     UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE
443   local user_confirm="$(GET user_confirm)"
444   local uid="${user_confirm% *}" signature="${user_confirm#* }"
445
446   if [ "$signature" = "$(session_mac "$uid")" ]; then
447     read -r UID UNAME   STATUS  EMAIL   PWSALT  PWHASH  EXPIRE  DEVICES FUTUREUSE <<-EOF
448         $(grep "^${uid} " "$user_db")
449         EOF
450     if [ "$STATUS" = pending -a "$EXPIRE" -gt "$_DATE" ]; then
451       cat <<-EOF
452         [form #user_confirm method=POST
453           [input type=hidden name=uid value="${uid}"]
454           [input type=hidden name=signature value="${signature}"]
455           [input disabled=disabled value="$(HTML "$EMAIL")"]
456           [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="^\[a-zA-Z\]\[a-zA-Z0-9 -~\]{2,127}$" autocomplete=off]
457           [input type=password name=pw placeholder="Choose Passphrase" pattern=".{6,}"]
458           [input type=password name=pwconfirm placeholder="Confirm Passphrase" pattern=".{6,}"]
459           [submit "action" "user_confirm" Finish Registration]
460         ]
461         EOF
462     else
463       cat <<-EOF
464         [div #user_confirm .expired
465           [p This activation link is not valid anymore.]
466         ]
467         EOF
468     fi
469   else
470     cat <<-EOF
471         [div #user_confirm .invalid
472           [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.]
473         ]
474         EOF
475   fi
476 }
477
478 w_user_invite(){
479   if [ "$(GET user_confirm)" ]; then
480     w_user_confirm
481   elif [ "$USER_ID" ]; then
482     cat <<-EOF
483         [form #user_invite method=POST
484           [input placeholder="Email Recipient" name=email autocomplete=off]
485           [textarea name="message" placeholder="Message to recipient" . ]
486           [submit "action" "user_invite" Send Invitation]
487         ]
488         EOF
489   else
490     cat <<-EOF
491         [div #user_invite .notallowed
492           Only registered users may send an invitation to another user.
493         ]
494         EOF
495   fi
496 }
497
498 w_user_login(){
499   if [ ! "$USER_ID" ]; then
500     cat <<-EOF
501         [form #user_login .login method=POST
502           [input name=uname placeholder="Username or Email" autocomplete=off]
503           [input type=password name=pw placeholder="Passphrase"]
504           [submit "action" "user_login" Login]
505         ]
506         EOF
507   elif [ "$USER_ID" ]; then
508     cat <<-EOF
509         [form #user_login .logout method=POST
510           [p Logged in as [span . $(HTML ${USER_NAME})]]
511           [submit "action" "user_logout" Logout]
512         ]
513         EOF
514   fi
515 }