diff --git a/app-racket-stories/control.rkt b/app-racket-stories/control.rkt index 181d5ba..9a89990 100644 --- a/app-racket-stories/control.rkt +++ b/app-racket-stories/control.rkt @@ -32,7 +32,7 @@ json (only-in db disconnect) "database.rkt" "def.rkt" "exn.rkt" "parameters.rkt" "structs.rkt" - "validation.rkt" + "validation.rkt" "mail.rkt" "model.rkt" "view.rkt" "secret.rkt") ;;; @@ -193,6 +193,7 @@ [("") (λ (req) (do-home req 0))] [("home") (λ (req) (do-home req 0))] [("home" "page" (integer-arg)) do-home] + [("submit") do-submit] ; new entry page [("new") (λ (req) (do-new req 0 ))] [("new" "page" (integer-arg)) do-new] [("popular") (λ (req) (do-popular req "week" 0))] @@ -202,8 +203,9 @@ [("profile") do-profile] ; show own user profile [("from" (integer-arg)) do-from] ; entries from same site as entry-id [("about") do-about] - [("login") do-login/create-account] - [("submit") do-submit] ; new entry page + [("login") do-login/create-account] + [("forgot") do-forgot] + [("password-recovery" (string-arg)) do-password-recovery] ; actions ; only recognize up-votes - use the next line if you need both @@ -220,11 +222,13 @@ [("associate-github") do-associate-github] ; form submissions - [("logout-submitted") #:method "post" do-logout-submitted] ; logout, then show front page - [("entry-submitted") #:method "post" do-entry-submitted] - [("login-submitted") #:method "post" do-login-submitted] - [("create-account-submitted") #:method "post" do-create-account-submitted] - [("profile-submitted") #:method "post" do-profile-submitted] + [("logout-submitted") #:method "post" do-logout-submitted] ; logout, then show front page + [("entry-submitted") #:method "post" do-entry-submitted] + [("login-submitted") #:method "post" do-login-submitted] + [("create-account-submitted") #:method "post" do-create-account-submitted] + [("profile-submitted") #:method "post" do-profile-submitted] + [("send-reset-password-submitted") #:method "post" do-send-reset-password-submitted] ; send mail + [("reset-password-submitted") #:method "post" do-reset-password-submitted] ; actual reset password [("github-login") do-github-login] ; initiate login (by user) [("github-callback") #:method "post" do-github-callback] ; callback (by github) @@ -315,6 +319,31 @@ (parameterize ([current-banner-message "To login with Github, you need to link your accounts."]) (do-login/create-account req))) +(define (do-forgot req) + (def result (html-forgot-page)) + (response/output (λ (out) (display result out)))) + +(define (do-send-reset-password-submitted req) + (def result (html-reset-password-sent-page)) + (def ue (get-binding #"usernameoremail" bytes->string/utf-8)) + (def u (or (get-user/username ue) (get-user/email ue))) + (def t (new-reset-password-token #:user u)) + (def url (~a "https://racket-stories.com/password-recovery/" t)) + (send-reset-password-email (user-email u) (user-username u) url) + (response/output (λ (out) (display result out)))) + +(define (do-password-recovery req token) + ; we get here when the user clicks an reset password link in his mail + (def result (html-password-recovery-page token)) + (response/output (λ (out) (display result out)))) + +(define (do-reset-password-submitted req ) + (def np (get-binding #"password")) ; bytes + (def token (get-binding #"token" bytes->string/utf-8)) + (reset-password token np) + (def result (html-password-was-reset-page)) + (response/output (λ (out) (display result out)))) + (define (do-login/create-account req) (def result (html-login-page)) diff --git a/app-racket-stories/mail.rkt b/app-racket-stories/mail.rkt new file mode 100644 index 0000000..fa6d566 --- /dev/null +++ b/app-racket-stories/mail.rkt @@ -0,0 +1,29 @@ +#lang racket/base +;;; +;;; Send mail using postmark +;;; + +(provide send-reset-password-email) + +(require postmark ; from the postmark-client package + json + "secret.rkt") ; contains the server api key + +(define key (postmark postmark-api-token)) + +(require json) + +(define (send-reset-password-email email name-of-user action-url) + ; The model values will be injected into the template named "password-reset". + ; The template can be edited on Postmark's homepage. + (define model (hasheq 'name name-of-user + 'action_url action-url)) + + (postmark-send-email-with-template + key + #:to email + #:from "passwordreset@racket-stories.com" + #:template-alias "password-reset" + #:template-model model + #:tag "reset-password")) + diff --git a/app-racket-stories/model.rkt b/app-racket-stories/model.rkt index dcd5d50..0da68bb 100644 --- a/app-racket-stories/model.rkt +++ b/app-racket-stories/model.rkt @@ -35,7 +35,9 @@ get-user authenticate-user change-user-about - + get-user/email + get-user/username + ;; Github users and state (except-out (schema-out github-user) make-github-user) login-github-user @@ -61,6 +63,10 @@ ; session-logged-in? session-terminate session-user-id + + ;; Password Resets + reset-password + new-reset-password-token ) @@ -377,6 +383,10 @@ (lookup db (~> (from user #:as u) (where (= username ,username))))) +(define (get-user/email email) + (lookup db (~> (from user #:as u) + (where (= email ,email))))) + (define (username-in-db? username) (positive? (lookup db (~> (from user #:as u) (select (count *)))))) @@ -697,6 +707,41 @@ (when (session-belongs-to-user? s u) (delete! db s))) +;;; +;;; RESET PASSWORD TOKENS +;;; + +(define-schema reset-password-token + ([id id/f #:primary-key #:auto-increment] + [user-id id/f] + [token string/f] + [expires-at datetime/f])) + +(define (new-reset-password-token #:user u) + (def token (bytes->hex-string (crypto-random-bytes 16))) + (def t (make-reset-password-token #:user-id (user-id u) + #:token token + #:expires-at (+period (now) (period [hours 42])))) + (insert-one! db t) + token) + +(define (get-reset-password-token/token token) + (lookup db (~> (from reset-password-token #:as t) + (where (= t.token ,token))))) + + +(define (reset-password-token-expired? reset-password-token) + (and reset-password-token + (datetime>? (now) (reset-password-token-expires-at reset-password-token)))) + +(define (reset-password token new-password) + (def rpt (get-reset-password-token/token token)) + (def u (get-user/id (reset-password-token-user-id rpt))) + (when (and rpt u (not (reset-password-token-expired? rpt))) + ; token fine, so we can now change the key + ; todo: remove the one time token + (update-one! db (update-user-key u (lambda (old-key) (derive-key new-password)))))) + ;;; ;;; DATABASE CREATION @@ -704,7 +749,7 @@ (define (populate-database) (when (= (count-users) 0) - (create-user "foo" #"foo" "foo@foo.com")) + (create-user "foo" #"foo" "jensaxel@soegaard.net")) (when (= (count-entries) 0) (define (create title url score) (create-entry #:title title #:url url #:score score #:submitter 1 #:submitter-name "foo")) @@ -736,7 +781,7 @@ HERE -(define schemas '(entry user vote session github-user github-state)) +(define schemas '(entry user vote session github-user github-state reset-password-token)) (define (create-tables) ; Note: This creates the tables if they don't exist. @@ -751,7 +796,6 @@ HERE (drop-table! db s)))) -; (drop-tables) (define (init-database) (create-tables) @@ -764,3 +808,6 @@ HERE [_ (void)])) +#;(begin (current-database (connect-to-database)) + (drop-tables) + (init-database)) diff --git a/app-racket-stories/secret.rkt b/app-racket-stories/secret.rkt index b0a80ed..02a39a9 100644 --- a/app-racket-stories/secret.rkt +++ b/app-racket-stories/secret.rkt @@ -15,7 +15,8 @@ ; https://lexi-lambda.github.io/envy/envy.html (provide github-client-id ; see prefs at github.com - github-client-secret + github-client-secret + postmark-api-token (rename-out [aes-decrypt decrypt]) (rename-out [aes-encrypt encrypt])) @@ -100,4 +101,14 @@ "7e44d624f9048f4812b254c82bf3818075dc95d568c15cd32e38769426914e82b58d49d02db3cf21")) +;;; +;;; Postmark +;;; + +; Postmark is used to send "transactional" emails. +; To begin with we use it to send "reset password" emails. + +(define postmark-api-token + (aes-decrypt + "7c4e8279fe02dd125bb90ecf2fb88cd27b888fd467c45fce786d24c92492488fbad74a83")) diff --git a/app-racket-stories/view.rkt b/app-racket-stories/view.rkt index 8936231..7042721 100644 --- a/app-racket-stories/view.rkt +++ b/app-racket-stories/view.rkt @@ -70,7 +70,12 @@ html-user-page html-profile-page html-from-page - html-associate-github-page) + html-associate-github-page + ; recovery + html-forgot-page + html-reset-password-sent-page + html-password-recovery-page + html-password-was-reset-page) ;; Dependencies @@ -784,6 +789,7 @@ @div[class: "row" @div[class: "col-sm-6 col-main" @nbsp + @h1{Login} @form[name: "loginform" action: "/login-submitted" method: "post"]{ @form-group{ @@ -829,9 +835,12 @@ @p{To link the accounts: @ul[@li{login to Racket Stories (with name/password)} @li{go to your profile page (click your username at the top, right)} - @li{click the "Sign in with Github" button}]}] + @li{click the "Sign in with Github" button}]} + @h1[class: "text-center"]{Forgot password?} + @a[href: "/forgot"]{I forgot my password}] ]]])) + (define (github-button) ; (def github-action-url "https://github.com/login/oauth/authorize?client_id=ec150ed77da7c0f796ec") @a[class: "btn btn-block" href: "/github-login" style: "border: 0;" @@ -841,7 +850,66 @@ style: "vertical-align:middle;"]{} @nbsp Sign in with Github}]]) +;;; +;;; Forgot Password Page +;;; + +(define (html-forgot-page) + (current-page "login") + (html-page + #:title "Forgot Password - Racket Stories" + #:body + @main-column/no-color[ + @nbsp + @h1{Reset Your Password} + + @p{If you can't remember your password, then enter either your username or your email. @(br) + We will send an email with further instructions.} + @form[name: "sendresetpassword" action: "/send-reset-password-submitted" method: "post"]{ + @form-group{@label[for: "usernameoremail"]{Username or email} + @form-input[name: "usernameoremail" type: "text" value: ""]} + @submit-button{Send reset mail}}])) + +(define (html-reset-password-sent-page) + (current-page "reset-password-sent") + (html-page + #:title "Password Reset Sent - Racket Stories" + #:body + @main-column/no-color[ + @nbsp + @h1{Mail Sent} + + @p{We have sent a mail with further instructions. @(br) + If you don't receive it, check you spam folder.}])) + +(define (html-password-recovery-page token) + (current-page "password-recory") + (html-page + #:title "Password Recovery - Racket Stories" + #:body + @main-column/no-color[ + @nbsp + @h1{Reset Password} + + @p{Enter your new password} + + @form[name: "resetpassword" action: "/reset-password-submitted" method: "post"]{ + @form-group{ + @label[for: "password"]{Password} + @form-input[name: "password" type: "password" value: ""]} + @input[type: "hidden" id: "token" name: "token" value: @|token|] + @submit-button{Reset Password}}])) + +(define (html-password-was-reset-page) + (current-page "password-recory") + (html-page + #:title "Password Was Reset - Racket Stories" + #:body + @main-column/no-color[ + @nbsp + @h1{Password Was Reset} + @p{You can now login with your new password.}])) ;;; ;;; Icons