/dylan/user.dylan

http://github.com/cgay/wiki · Unknown · 761 lines · 664 code · 97 blank · 0 comment · 0 complexity · 02231bf85ca84ab1b2261e4276d59d34 MD5 · raw file

  1. Module: %wiki
  2. Synopsis: User account management.
  3. // User data is stored in a separate git repository so that it can
  4. // be maintained under stricter security than other data.
  5. define thread variable *user-username* = #f;
  6. define thread variable *authenticated-user* = #f;
  7. // The default "realm" value passed in the WWW-Authenticate header.
  8. //
  9. define variable *default-authentication-realm* :: <string> = "dylan-wiki";
  10. // Because clients (browsers) continue to send the Authentication header
  11. // once an authentication has been accepted (at least until the browser
  12. // is restarted, it seems) we need to keep track of the fact that a user
  13. // has logged out by storing the auth values here.
  14. //
  15. // Also, note that if the server restarts and browsers resend the auth,
  16. // the user is suddenly logged in again. Yikes.
  17. //
  18. define variable *ignore-authorizations* = list();
  19. define variable *ignore-logins* = list();
  20. // TODO: options to specify which fields are visible to whom. (acls)
  21. //
  22. define class <wiki-user> (<wiki-object>)
  23. constant slot %user-real-name :: false-or(<string>) = #f,
  24. init-keyword: real-name:;
  25. slot user-password :: <string>,
  26. required-init-keyword: password:;
  27. slot user-email :: <string>,
  28. required-init-keyword: email:;
  29. slot administrator? :: <boolean> = #f,
  30. init-keyword: administrator?:;
  31. slot user-activation-key :: <string>,
  32. init-keyword: activation-key:;
  33. slot user-activated? :: <boolean> = #f,
  34. init-keyword: activated?:;
  35. end class <wiki-user>;
  36. define method initialize
  37. (user :: <wiki-user>, #key)
  38. next-method();
  39. if (~slot-initialized?(user, user-activation-key))
  40. user.user-activation-key := generate-activation-key(user);
  41. end;
  42. end;
  43. // back compat
  44. define inline function user-name
  45. (user :: <wiki-user>) => (name :: <string>)
  46. user.object-name
  47. end;
  48. // back compat
  49. define inline function user-name-setter
  50. (new-name :: <string>, user :: <wiki-user>) => (new-name :: <string>)
  51. user.object-name := new-name
  52. end;
  53. define generic user-real-name
  54. (user :: <wiki-user>) => (real-name :: <string>);
  55. define method user-real-name
  56. (user :: <wiki-user>) => (real-name :: <string>)
  57. user.%user-real-name | user.user-name
  58. end;
  59. define function find-user
  60. (name :: <string>, #key default)
  61. => (user :: false-or(<wiki-user>))
  62. element(*users*, name, default: default)
  63. end;
  64. /* unused
  65. define method user-exists?
  66. (name :: <string>) => (exists? :: <boolean>)
  67. find-user(name) & #t
  68. end;
  69. */
  70. // This is set when the config file is loaded.
  71. define variable *admin-user* :: false-or(<wiki-user>) = #f;
  72. define method generate-activation-key
  73. (user :: <wiki-user>)
  74. => (key :: <string>)
  75. // temporary. should be more secure.
  76. base64-encode(concatenate(user.user-name, user.user-email))
  77. end;
  78. // What's this for?
  79. define method as (class == <string>, user :: <wiki-user>)
  80. => (result :: <string>)
  81. user.user-name;
  82. end;
  83. define function authenticated-user ()
  84. => (user :: false-or(<wiki-user>))
  85. authenticate();
  86. *authenticated-user*
  87. end;
  88. define method \=
  89. (user1 :: <wiki-user>, user2 :: <wiki-user>)
  90. => (equal? :: <boolean>)
  91. user1.user-name = user2.user-name
  92. end;
  93. define method login
  94. (#key realm :: false-or(<string>))
  95. let redirect-url = get-query-value("redirect");
  96. let user = check-authorization();
  97. if (~user)
  98. require-authorization(realm: realm);
  99. elseif (member?(user, *ignore-authorizations*, test: \=) &
  100. member?(user, *ignore-logins*, test: \=))
  101. *ignore-authorizations* := remove!(*ignore-authorizations*, user);
  102. require-authorization(realm: realm);
  103. elseif (~member?(user, *ignore-authorizations*, test: \=) &
  104. member?(user, *ignore-logins*, test: \=))
  105. *ignore-logins* := remove!(*ignore-logins*, user);
  106. redirect-url & redirect-to(redirect-url);
  107. else
  108. redirect-url & redirect-to(redirect-url);
  109. end if;
  110. end;
  111. define function logout ()
  112. let user = check-authorization();
  113. if (user)
  114. *authenticated-user* := #f;
  115. *ignore-authorizations* :=
  116. add!(*ignore-authorizations*, user);
  117. *ignore-logins* :=
  118. add!(*ignore-logins*, user);
  119. end if;
  120. let redirect-url = get-query-value("redirect");
  121. redirect-url & redirect-to(redirect-url);
  122. end;
  123. // TODO: this should signal an error, which we can handle in one place
  124. // and redirect to a login page.
  125. define function check-authorization
  126. () => (user :: false-or(<wiki-user>))
  127. let authorization = get-header(current-request(), "Authorization", parsed: #t);
  128. if (authorization)
  129. let name = head(authorization);
  130. let pass = tail(authorization);
  131. let user = find-user(name);
  132. if (user
  133. & user.user-activated?
  134. & user.user-password = pass)
  135. user
  136. end
  137. end
  138. end function check-authorization;
  139. define function authenticate
  140. () => (user :: false-or(<wiki-user>))
  141. let user = check-authorization();
  142. if (user)
  143. *authenticated-user*
  144. := if (~member?(user, *ignore-authorizations*, test: \=)
  145. & ~member?(user, *ignore-logins*, test: \=))
  146. user
  147. end;
  148. end
  149. end function authenticate;
  150. define function require-authorization
  151. (#key realm :: false-or(<string>))
  152. let realm = realm | *default-authentication-realm*;
  153. let headers = current-response().raw-headers;
  154. set-header(headers, "WWW-Authenticate", concatenate("Basic realm=\"", realm, "\""));
  155. unauthorized-error(headers: headers);
  156. end;
  157. define wf/object-test (user) in wiki end;
  158. define wf/error-tests (username, password, email) in wiki end;
  159. define sideways method permanent-link
  160. (user :: <wiki-user>) => (url :: <url>)
  161. user-permanent-link(user.user-name);
  162. end;
  163. define method user-permanent-link
  164. (username :: <string>)
  165. => (uri :: <uri>)
  166. let location = wiki-url("/user/view/%s", username);
  167. transform-uris(request-url(current-request()), location, as: <url>);
  168. end;
  169. define sideways method redirect-to (user :: <wiki-user>)
  170. redirect-to(permanent-link(user));
  171. end;
  172. define method send-new-account-email
  173. (user :: <wiki-user>)
  174. let url = account-activation-url(user);
  175. // body contains "subject\n\n"...weird
  176. let body = format-to-string(
  177. "To: %s\nSubject: Confirmation for account %s on %s\n\n"
  178. "This message is to confirm the account '%s' you registered on %s. "
  179. "Click the following URL to complete the registration process and "
  180. "activate your new account: %s\n",
  181. user.user-email, user.user-name, *site-name*,
  182. user.user-name, *site-name*, url);
  183. // Try to send the message.
  184. // Retry once if we get 451, to work around grey listing.
  185. iterate loop (first? = #t)
  186. let handler <transient-smtp-error> = method (ex, next-handler)
  187. if (first? & ex.smtp-error-code = 451)
  188. loop(#f);
  189. else
  190. next-handler();
  191. end;
  192. end;
  193. send-smtp-message(host: *mail-host*,
  194. port: *mail-port*,
  195. recipients: list(user.user-email),
  196. from: *admin-user*.user-email,
  197. body: body);
  198. log-info("Email verification sent to %s for user %s",
  199. user.user-email, user.user-name);
  200. end;
  201. end method send-new-account-email;
  202. define method account-activation-url
  203. (user :: <wiki-user>)
  204. => (url :: <string>)
  205. let default = current-request().request-absolute-url;
  206. let prefix = iff(*wiki-url-prefix*.size = 0,
  207. #(),
  208. split(*wiki-url-prefix*, "/", remove-if-empty?: #t));
  209. as(<string>,
  210. make(<url>,
  211. scheme: "http",
  212. host: default.uri-host,
  213. port: default.uri-port,
  214. path: concatenate(list(""),
  215. prefix,
  216. list("user", "activate", user.user-name,
  217. user.user-activation-key))))
  218. end method account-activation-url;
  219. // This is pretty restrictive for now. Easier to loosen the rules later
  220. // than to tighten them up. The name has been pre-stripped and %-decoded.
  221. //
  222. define method validate-user-name
  223. (name :: <string>) => (name :: <string>)
  224. if (empty?(name))
  225. error("A user name is required.");
  226. elseif (~regex-search(compile-regex("^[A-Za-z0-9_-]+$"), name))
  227. error("User names must contain only alphanumerics, hyphens and underscores.");
  228. end;
  229. name
  230. end;
  231. define method validate-password
  232. (password :: <string>) => (password :: <string>)
  233. if (password.size <= 3)
  234. error("A password of four or more characters is required.");
  235. end;
  236. password
  237. end;
  238. define method validate-email
  239. (email :: <string>) => (email :: <string>)
  240. // Just checking some basic syntax for now. Will eventually send mail
  241. // to verify.
  242. let parts = split(email, '@');
  243. if (parts.size ~= 2
  244. | parts[0].size = 0
  245. | parts[1].size = 0
  246. | ~member?('.', parts[1]))
  247. error("Invalid email address syntax.");
  248. end;
  249. email
  250. end;
  251. //// List Users
  252. define class <list-users-page> (<wiki-dsp>)
  253. end;
  254. define method respond-to-get
  255. (page :: <list-users-page>, #key)
  256. let pc = page-context();
  257. set-attribute(pc, "active-users",
  258. map(method (user)
  259. make-table(<string-table>,
  260. "name" => user.user-name,
  261. "admin?" => user.administrator?)
  262. end,
  263. choose(user-activated?,
  264. load-all(*storage*, <wiki-user>))));
  265. let active-user = authenticated-user();
  266. set-attribute(pc, "active-user", active-user & active-user.user-name);
  267. next-method();
  268. end;
  269. define method respond-to-post
  270. (page :: <list-users-page>, #key)
  271. let user-name = percent-decode(get-query-value("user-name"));
  272. let user = find-user(user-name);
  273. if (user)
  274. respond-to-get(*view-user-page*, name: user-name);
  275. else
  276. add-field-error("user-name", "User '%s' not found.", user-name);
  277. next-method();
  278. end;
  279. end method respond-to-post;
  280. //// View User
  281. define class <view-user-page> (<wiki-dsp>)
  282. end;
  283. define method respond-to-get
  284. (page :: <view-user-page>, #key name :: <string>)
  285. let name = percent-decode(name);
  286. let user = find-user(name);
  287. if (user)
  288. let pc = page-context();
  289. set-attribute(pc, "user-name", user.user-name);
  290. set-attribute(pc, "group-memberships",
  291. sort(map(group-name, user-groups(user))));
  292. set-attribute(pc, "group-ownerships",
  293. sort(map(group-name, groups-owned-by-user(user))));
  294. set-attribute(pc, "admin?", user.administrator?);
  295. let active-user = authenticated-user();
  296. set-attribute(pc, "user-email",
  297. if (active-user & (active-user = user
  298. | administrator?(active-user)))
  299. user.user-email
  300. else
  301. "private"
  302. end);
  303. next-method();
  304. else
  305. // should only get here via a manually typed-in URL
  306. respond-to-get(*non-existing-user-page*, name: name);
  307. end;
  308. end method respond-to-get;
  309. //// Registration Page
  310. // This is similar to Edit User except that the account MUST NOT exist yet.
  311. //
  312. define class <registration-page> (<wiki-dsp>)
  313. end;
  314. define method respond-to-get
  315. (page :: <registration-page>, #key)
  316. let active-user = authenticated-user();
  317. if (active-user)
  318. add-page-note("You are already logged in. Log out to register a new account.");
  319. respond-to-get(*view-user-page*, name: active-user.user-name);
  320. else
  321. next-method();
  322. end;
  323. end method respond-to-get;
  324. define method respond-to-post
  325. (page :: <registration-page>, #key)
  326. let active-user = authenticated-user();
  327. if (active-user)
  328. add-page-note("You are already logged in. Log out to register a new account.");
  329. respond-to-get(*view-user-page*, name: active-user.user-name);
  330. else
  331. let new-name = validate-form-field("user-name", validate-user-name);
  332. let email = validate-form-field("email", validate-email);
  333. let password = validate-form-field("password", validate-password);
  334. let password2 = validate-form-field("password2", validate-password);
  335. if (password ~= password2)
  336. add-field-error("password2", "Passwords don't match.");
  337. end;
  338. // Hold this user name, by adding it to *users*, while email is being sent.
  339. // It will be removed if there are any further errors.
  340. let user
  341. = if (find-user(new-name))
  342. add-field-error("user-name", "A user named %s already exists.", new-name);
  343. #f
  344. else
  345. with-lock ($user-lock)
  346. // check again with lock held
  347. if (find-user(new-name))
  348. add-field-error("user-name", "A user named %s already exists.", new-name);
  349. #f
  350. else
  351. *users*[new-name] := make(<wiki-user>,
  352. name: new-name,
  353. real-name: #f, // TODO
  354. password: password,
  355. email: email,
  356. administrator?: #f);
  357. end;
  358. end;
  359. end if;
  360. if (user & ~page-has-errors?())
  361. // Hannes commented in IRC 2009-06-12: this will probably block
  362. // the responder thread while the mail is being delivered; and
  363. // to circumvent greylisting you've to wait 5-10 minutes between
  364. // the first and second attempt. I'd suggest a separate thread
  365. // which cares about email notifications, and the responder
  366. // thread to push a message to a queue which is popped by the
  367. // email thread...
  368. block ()
  369. send-new-account-email(user);
  370. exception (ex :: <serious-condition>)
  371. log-error("Email failed to %s (for %s): %s",
  372. user.user-email, user.user-name, ex);
  373. add-field-error("email",
  374. "Unable to send confirmation email to this address.");
  375. end;
  376. end;
  377. // Check again for errors since sending mail may have failed.
  378. if (page-has-errors?())
  379. with-lock($user-lock)
  380. remove-key!(*users*, new-name);
  381. end;
  382. next-method();
  383. else
  384. block ()
  385. store(*storage*, user, user, "New user created",
  386. standard-meta-data(user, "create"));
  387. with-lock ($user-lock)
  388. *users*[user.user-name] := user;
  389. end;
  390. add-page-note("User %s created. Please follow the link in the confirmation "
  391. "email sent to %s to activate the account.",
  392. new-name, email);
  393. respond-to-get(*view-user-page*, name: user.user-name);
  394. exception (ex :: <serious-condition>)
  395. with-lock($user-lock)
  396. remove-key!(*users*, new-name);
  397. end;
  398. end;
  399. end if;
  400. end if;
  401. end method respond-to-post;
  402. //// User activation/deactivation
  403. // Responder for the URL sent in confirmation email to activate the account.
  404. //
  405. define function respond-to-user-activation-request
  406. (#key name :: <string>, key :: <string>)
  407. let name = percent-decode(name);
  408. let user = find-user(name);
  409. if (user)
  410. if (~user.user-activated?)
  411. let key = percent-decode(key);
  412. if (key = user.user-activation-key)
  413. user.user-activated? := #t;
  414. store(*storage*, user, *admin-user*, "Account activated",
  415. standard-meta-data(user, "activate"));
  416. end;
  417. end;
  418. if (user.user-activated?)
  419. add-page-note("User %s activated.", name);
  420. else
  421. add-page-error("User activation failed.");
  422. end;
  423. else
  424. add-page-error("User %s not found.", name);
  425. end;
  426. respond-to-get(*view-user-page*, name: name);
  427. end function respond-to-user-activation-request;
  428. define class <deactivate-user-page> (<wiki-dsp>)
  429. end;
  430. define method respond-to-get
  431. (dsp :: <deactivate-user-page>, #key name :: <string>)
  432. dynamic-bind (*user* = find-user(name))
  433. next-method();
  434. end;
  435. end;
  436. define method respond-to-post
  437. (dsp :: <deactivate-user-page>, #key name :: <string>)
  438. dynamic-bind (*user* = find-user(name))
  439. let author = authenticated-user();
  440. if (author
  441. & (author = *user* | author.administrator?)
  442. & (*user* ~= *admin-user*))
  443. let comment = get-query-value("comment") | "Deactivated";
  444. store(*storage*, *user*, author, comment,
  445. standard-meta-data(*user*, "deactivate"));
  446. *user*.user-activated? := #f;
  447. add-page-note("User '%s' deactivated", *user*.user-name);
  448. respond-to-get(*list-users-page*);
  449. else
  450. add-page-error("You don't have permission to deactivate this user.");
  451. respond-to-get(*view-user-page*, name: name);
  452. end;
  453. end;
  454. end method respond-to-post;
  455. //// Edit User
  456. // This is similar to <registration-page> except the user MUST exist.
  457. //
  458. define class <edit-user-page> (<wiki-dsp>)
  459. end;
  460. define method respond-to-get
  461. (page :: <edit-user-page>, #key name :: <string>)
  462. let name = percent-decode(name);
  463. let user = find-user(name);
  464. let active-user = authenticated-user();
  465. let pc = page-context();
  466. set-attribute(pc, "user-name", name);
  467. set-attribute(pc, "button-text", iff(user, "Save", "Create"));
  468. set-attribute(pc, "active-user-is-admin?",
  469. active-user & administrator?(active-user));
  470. if (user & (active-user = user | (active-user & administrator?(active-user))))
  471. set-attribute(pc, "password", user.user-password);
  472. set-attribute(pc, "email", user.user-email);
  473. set-attribute(pc, "admin?", user.administrator?);
  474. next-method();
  475. else
  476. add-page-error("You don't have permission to change this user.");
  477. respond-to-get(*view-user-page*, name: name);
  478. end;
  479. end method respond-to-get;
  480. define method respond-to-post
  481. (page :: <edit-user-page>, #key name :: <string>)
  482. let name = percent-decode(name);
  483. let user = find-user(name);
  484. let active-user = authenticated-user();
  485. if (user & (~active-user | ~(active-user = user | active-user.administrator?)))
  486. add-page-error("You don't have permission to change this user.");
  487. respond-to-get(*view-user-page*, name: name);
  488. else
  489. let new-name = validate-form-field("user-name", validate-user-name);
  490. if (name ~= new-name & find-user(new-name))
  491. add-field-error("user-name", "A user named %s already exists.", new-name);
  492. end;
  493. let email = validate-form-field("email", validate-email);
  494. let password = validate-form-field("password", validate-password);
  495. let admin? = get-query-value("admin?");
  496. if (page-has-errors?())
  497. next-method(); // redisplay page with errors
  498. else
  499. if (user)
  500. let comments = make(<stretchy-vector>);
  501. if (user.user-name ~= new-name)
  502. let comment = sformat("Rename to %s", new-name);
  503. rename-user(user, new-name, comment);
  504. add!(comments, comment);
  505. end;
  506. if (user.user-password ~= password)
  507. user.user-password := password;
  508. add!(comments, "password changed");
  509. end;
  510. if (user.user-email ~= email)
  511. user.user-email := email;
  512. add!(comments, "email changed");
  513. end;
  514. if (user.administrator? ~= admin?)
  515. user.administrator? := admin?;
  516. add!(comments, format-to-string("%s admin status",
  517. iff(admin?, "added", "removed")));
  518. end;
  519. store(*storage*, user, active-user, join(comments, ", "),
  520. standard-meta-data(user, "edit"));
  521. add-page-note("User %s updated.", new-name);
  522. else
  523. // new user
  524. user := make(<wiki-user>,
  525. name: new-name,
  526. password: password,
  527. email: email,
  528. administrator?: admin?);
  529. store(*storage*, user, active-user, "User created",
  530. standard-meta-data(user, "create"));
  531. with-lock ($user-lock)
  532. *users*[new-name] := user;
  533. end;
  534. add-page-note("User %s created.", new-name);
  535. login(realm: *wiki-realm*);
  536. end;
  537. redirect-to(user);
  538. end if;
  539. end if;
  540. end method respond-to-post;
  541. define function rename-user
  542. (user :: <wiki-user>, new-name :: <string>, comment :: <string>)
  543. => ()
  544. let author = authenticated-user();
  545. let revision = rename(*storage*, user, new-name, author, comment,
  546. standard-meta-data(user, "rename"));
  547. let old-name = user.user-name;
  548. with-lock ($user-lock)
  549. remove-key!(*users*, old-name);
  550. *users*[new-name] := user;
  551. end;
  552. user.user-name := new-name;
  553. // user.user-revision := revision;
  554. end function rename-user;
  555. /* unused
  556. define method redirect-to-user-or
  557. (page :: <wiki-dsp>, #key username)
  558. if (*user*)
  559. respond-to-get(page);
  560. else
  561. redirect-to(user-permanent-link(percent-decode(username)));
  562. end if;
  563. end;
  564. */
  565. // tags
  566. define tag show-user-username in wiki (page :: <wiki-dsp>)
  567. ()
  568. output("%s", (*user* & escape-xml(*user*.user-name))
  569. | get-query-value("username")
  570. | *user-username*
  571. | "");
  572. end;
  573. define tag show-user-email in wiki (page :: <wiki-dsp>)
  574. ()
  575. output("%s", (*user* & escape-xml(*user*.user-email))
  576. | get-query-value("email")
  577. | "");
  578. end;
  579. define tag show-user-permanent-link in wiki (page :: <wiki-dsp>)
  580. (use-change :: <boolean>)
  581. if (*user*)
  582. output("%s", permanent-link(*user*))
  583. end;
  584. end;
  585. // body tags
  586. define body tag list-users in wiki
  587. (page :: <wiki-dsp>, do-body :: <function>)
  588. ()
  589. if (*users*.size == 0)
  590. // todo -- quick hack. replace wiki:list-users with dsp:do
  591. output("<li>No users</li>");
  592. else
  593. for (user in *users*)
  594. dynamic-bind(*user* = user)
  595. do-body();
  596. end;
  597. end for;
  598. end;
  599. end tag list-users;
  600. define body tag with-authenticated-user in wiki
  601. (page :: <wiki-dsp>, do-body :: <function>)
  602. ()
  603. dynamic-bind(*user* = authenticated-user())
  604. do-body();
  605. end;
  606. end;
  607. // named methods
  608. define named-method logged-in? in wiki
  609. (page :: <wiki-dsp>)
  610. authenticated-user() ~= #f
  611. end;
  612. define named-method admin? in wiki (page :: <wiki-dsp>)
  613. *user* & administrator?(*user*)
  614. end;
  615. define named-method user-group-names in wiki (page :: <wiki-dsp>)
  616. if (*user*)
  617. sort(map(group-name, user-groups(*user*)))
  618. else
  619. #[]
  620. end;
  621. end;
  622. define named-method group-names-owned-by-user in wiki (page :: <wiki-dsp>)
  623. if (*user*)
  624. sort(map(group-name, groups-owned-by-user(*user*)))
  625. else
  626. #[]
  627. end;
  628. end;
  629. define named-method can-modify-user?
  630. (page :: <wiki-dsp>)
  631. let user = authenticated-user();
  632. user & (administrator?(user)
  633. | begin
  634. let user-name = get-attribute(page-context(), "user-name");
  635. user-name & (find-user(percent-decode(user-name)) = user)
  636. end)
  637. end;
  638. define tag show-login-url in wiki (page :: <dylan-server-page>)
  639. (redirect :: type-union(<string>, <boolean>), current :: <boolean>)
  640. let url = parse-url("/login");
  641. if (redirect)
  642. url.uri-query["redirect"] := if (current)
  643. build-uri(request-url(current-request()))
  644. else
  645. redirect
  646. end;
  647. end if;
  648. output("%s", url);
  649. end;
  650. define tag show-logout-url in wiki (page :: <dylan-server-page>)
  651. (redirect :: type-union(<string>, <boolean>), current :: <boolean>)
  652. let url = parse-url("/logout");
  653. if (redirect)
  654. url.uri-query["redirect"] := if (current)
  655. build-uri(request-url(current-request()))
  656. else
  657. redirect
  658. end;
  659. end if;
  660. output("%s", url);
  661. end;
  662. define named-method authenticated? in wiki (page :: <dylan-server-page>)
  663. authenticated-user()
  664. end;