From 249031b9a8b0fb8c3494967bd6b3722a209c1ae0 Mon Sep 17 00:00:00 2001 From: Dominik Picheta Date: Sun, 20 May 2018 15:03:56 +0100 Subject: [PATCH] Implements /resetPassword and refactors email code. --- src/auth.nim | 1 - src/email.nim | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/forum.nim | 120 +++++++++++++++++--------------------------- src/utils.nim | 106 +++++++-------------------------------- 4 files changed, 199 insertions(+), 162 deletions(-) create mode 100644 src/email.nim diff --git a/src/auth.nim b/src/auth.nim index 3da20a8..72df257 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -52,7 +52,6 @@ proc makeIdentHash*(user, password, epoch, secret: string, ## If ``epoch`` is smaller than the epoch of the user's last login then ## the link is invalid. ## The ``secret`` is the 'salt' field in the ``person`` table. - echo(user, password, epoch, secret) when defined(windows): result = getMD5(user & password & epoch & secret) else: diff --git a/src/email.nim b/src/email.nim new file mode 100644 index 0000000..e2e8973 --- /dev/null +++ b/src/email.nim @@ -0,0 +1,134 @@ +import asyncdispatch, smtp, strutils, times, cgi, tables + +from jester import Request, makeUri + +import utils, auth + +type + Mailer* = ref object + config: Config + lastReset: Time + emailsSent: CountTable[string] + +proc newMailer*(config: Config): Mailer = + Mailer( + config: config, + lastReset: getTime(), + emailsSent: initCountTable[string]() + ) + +proc rateCheck(mailer: Mailer, address: string): bool = + ## Returns true if we've emailed the address too much. + let diff = getTime() - mailer.lastReset + if diff.hours >= 1: + mailer.lastReset = getTime() + mailer.emailsSent.clear() + + result = address in mailer.emailsSent and mailer.emailsSent[address] >= 2 + mailer.emailsSent.inc(address) + +proc sendMail( + mailer: Mailer, + subject, message, recipient: string, + fromAddr = "forum@nim-lang.org", + otherHeaders:seq[(string, string)] = @[] +) {.async.} = + # Ensure we aren't emailing this address too much. + if rateCheck(mailer, recipient): + let msg = "Too many messages have been sent to this email address recently." + raise newForumError(msg) + + if mailer.config.smtpAddress.len == 0: + echo("[WARNING] Cannot send mail: no smtp server configured (smtpAddress).") + return + + var client = newAsyncSmtp() + await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort)) + if mailer.config.smtpUser.len > 0: + await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword) + + let toList = @[recipient] + + var headers = otherHeaders + headers.add(("From", fromAddr)) + + let encoded = createMessage(subject, message, + toList, @[], headers) + + await client.sendMail(fromAddr, toList, $encoded) + +proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} = + let message = """Hello $1, +A password reset has been requested for your account on the $3. + +If you did not make this request, you can safely ignore this email. +A password reset request can be made by anyone, and it does not indicate +that your account is in any danger of being accessed by someone else. + +If you do actually want to reset your password, visit this link: + + $2 + +Thank you for being a part of our community! +""" % [user, resetUrl, mailer.config.name] + + let subject = mailer.config.name & " Password Recovery" + await sendMail(mailer, subject, message, email) + +proc sendEmailActivation( + mailer: Mailer, + email, user, activateUrl: string +) {.async.} = + let message = """Hello $1, +You have recently registered an account on the $3. + +As the final step in your registration, we require that you confirm your email +via the following link: + + $2 + +Thank you for registering and becoming a part of our community! +""" % [user, activateUrl, mailer.config.name] + let subject = mailer.config.name & " Account Email Confirmation" + await sendMail(mailer, subject, message, email) + +type + SecureEmailKind* = enum + ActivateEmail, ResetPassword + +proc sendSecureEmail*( + mailer: Mailer, + kind: SecureEmailKind, req: Request, + name, password, email, salt: string +) {.async.} = + let epoch = $int(epochTime()) + + let path = + case kind + of ActivateEmail: + "activateEmail" + of ResetPassword: + "resetPassword" + let url = req.makeUri( + "/$#?nick=$#&epoch=$#&ident=$#" % + [ + path, + encodeUrl(name), + encodeUrl(epoch), + encodeUrl(makeIdentHash(name, password, epoch, salt)) + ] + ) + + let emailSentFut = + case kind + of ActivateEmail: + sendEmailActivation(mailer, email, name, url) + of ResetPassword: + sendPassReset(mailer, email, name, url) + yield emailSentFut + if emailSentFut.failed: + echo("[WARNING] Couldn't send email: ", emailSentFut.error.msg) + if emailSentFut.error of ForumError: + raise emailSentFut.error + else: + raise newForumError("Couldn't send email", @["email"]) \ No newline at end of file diff --git a/src/forum.nim b/src/forum.nim index a98c04d..36f83fb 100644 --- a/src/forum.nim +++ b/src/forum.nim @@ -9,13 +9,14 @@ import os, strutils, times, md5, strtabs, math, db_sqlite, scgi, jester, asyncdispatch, asyncnet, sequtils, - parseutils, utils, random, rst, recaptcha, json, re, sugar + parseutils, random, rst, recaptcha, json, re, sugar, + strformat import cgi except setCookie import options import sass -import auth +import auth, email, utils import frontend/threadlist except User import frontend/[ @@ -63,24 +64,12 @@ type noPagenumumNav: bool config: Config - ForumError = object of Exception - data: PostError - var db: DbConn isFTSAvailable: bool config: Config captcha: ReCaptcha - -proc newForumError(message: string, - fields: seq[string] = @[]): ref ForumError = - new(result) - result.msg = message - result.data = - PostError( - errorFields: fields, - message: message - ) + mailer: Mailer proc init(c: TForumData) = c.userPass = "" @@ -131,42 +120,23 @@ proc setError(c: TForumData, field, msg: string): bool {.inline.} = c.errorMsg = "Error: " & msg return false -proc resetPassword(c: TForumData, nick, antibot, userIp: string): Future[bool] {.async.} = - # captcha validation: - if config.recaptchaSecretKey.len > 0: - var captchaValid: bool = false - try: - captchaValid = await captcha.verify(antibot, userIp) - except: - echo("[ERROR] Error checking captcha: " & getCurrentExceptionMsg()) - captchaValid = false - - if not captchaValid: - return setError(c, "g-recaptcha-response", "Answer to captcha incorrect!") - +proc resetPassword( + c: TForumData, + email: string +) {.async.} = # Gather some extra information to determine ident hash. - let epoch = $int(epochTime()) let row = db.getRow( - sql"select password, salt, email from person where name = ?", nick) + sql"select name, password, email, salt from person where email = ?", + email + ) if row[0] == "": - return setError(c, "nick", "Nickname not found") - # Generate URL for the email. - # TODO: Get rid of the stupid `%` in main.tmpl as it screws up strutils.% - let resetUrl = c.req.makeUri( - strutils.`%`("/emailResetPassword?nick=$1&epoch=$2&ident=$3", - [encodeUrl(nick), encodeUrl(epoch), - encodeUrl(makeIdentHash(nick, row[0], epoch, row[1]))])) - echo "User's reset URL is: ", resetUrl - # Send the email. - let emailSentFut = sendPassReset(c.config, row[2], nick, resetUrl) - # TODO: This is a workaround for 'var T' not being usable in async procs. - while not emailSentFut.finished: - poll() - if emailSentFut.failed: - echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) - return setError(c, "email", "Couldn't send activation email") + raise newForumError("Email not found", @["email"]) - return true + await sendSecureEmail( + mailer, + ResetPassword, c.req, + row[0], row[1], row[2], row[3] + ) proc logout(c: TForumData) = const query = sql"delete from session where ip = ? and password = ?" @@ -279,6 +249,8 @@ proc initialise() = doAssert config.isDev, "Recaptcha required for production!" echo("[WARNING] No recaptcha secret key specified.") + mailer = newMailer(config) + db = open(connection=config.dbPath, user="", password="", database="nimforum") isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & @@ -582,19 +554,6 @@ proc executeLogin(c: TForumData, username, password: string): string = raise newForumError("Invalid username or password") -proc sendEmailActivation(c: TForumData, name, password, - email, salt: string) {.async.} = - let epoch = $int(epochTime()) - let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" % - [encodeUrl(name), encodeUrl(epoch), - encodeUrl(makeIdentHash(name, password, epoch, salt))]) - - let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl) - yield emailSentFut - if emailSentFut.failed: - echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) - raise newForumError("Couldn't send activation email", @["email"]) - proc executeRegister(c: TForumData, name, pass, antibot, userIp, email: string): Future[string] {.async.} = ## Registers a new user and returns a new session key for that user's @@ -632,7 +591,9 @@ proc executeRegister(c: TForumData, name, pass, antibot, userIp, let password = makePassword(pass, salt) # Send activation email. - await sendEmailActivation(c, name, password, email, salt) + await sendSecureEmail( + mailer, ActivateEmail, c.req, name, password, email, salt + ) # Add account to person table exec(db, sql""" @@ -726,7 +687,9 @@ proc updateProfile( if rank != EmailUnconfirmed: raise newForumError("Rank needs a change when setting new email.") - await sendEmailActivation(c, row[0], row[1], row[2], row[3]) + await sendSecureEmail( + mailer, ActivateEmail, c.req, row[0], row[1], row[2], row[3] + ) exec( db, @@ -1193,7 +1156,7 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" - post re"/deleteUser": + post "/deleteUser": createTFD() if not c.loggedIn(): let err = PostError( @@ -1213,7 +1176,7 @@ routes: except ForumError as exc: resp Http400, $(%exc.data), "application/json" - post re"/saveProfile": + post "/saveProfile": createTFD() if not c.loggedIn(): let err = PostError( @@ -1238,6 +1201,25 @@ routes: let exc = (ref ForumError)(getCurrentException()) resp Http400, $(%exc.data), "application/json" + post "/resetPassword": + createTFD() + if not c.loggedIn(): + let err = PostError( + errorFields: @[], + message: "Not logged in." + ) + resp Http401, $(%err), "application/json" + + let formData = request.formData + cond "email" in formData + try: + await resetPassword(c, formData["email"].body) + resp Http200, "{}", "application/json" + except ForumError: + let exc = (ref ForumError)(getCurrentException()) + resp Http400, $(%exc.data), "application/json" + + get "/t/@id": cond "id" in request.params @@ -1342,16 +1324,6 @@ routes: # else: # resp genMain(c, "Invalid ident hash", "Nim Forum") - post "/doresetpassword": - createTFD() - echo(request.params) - cond(@"nick" != "") - - # if await resetPassword(c, @"nick", @"g-recaptcha-response", request.host): - # resp genMain(c, "Email sent!", "Reset Password - Nim Forum") - # else: - # resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") - post "/search/?@page?": cond isFTSAvailable createTFD() diff --git a/src/utils.nim b/src/utils.nim index 918ed32..c4ecfec 100644 --- a/src/utils.nim +++ b/src/utils.nim @@ -7,7 +7,7 @@ from times import getTime, getGMTime, format let UsernameIdent* = IdentChars # TODO: Double check that everyone follows this. -import frontend/karaxutils +import frontend/[karaxutils, error] export parseInt proc `%`*[T](opt: Option[T]): JsonNode = @@ -17,11 +17,11 @@ proc `%`*[T](opt: Option[T]): JsonNode = type Config* = object - smtpAddress: string - smtpPort: int - smtpUser: string - smtpPassword: string - mlistAddress: string + smtpAddress*: string + smtpPort*: int + smtpUser*: string + smtpPassword*: string + mlistAddress*: string recaptchaSecretKey*: string recaptchaSiteKey*: string isDev*: bool @@ -29,6 +29,19 @@ type hostname*: string name*: string + ForumError* = object of Exception + data*: PostError + +proc newForumError*(message: string, + fields: seq[string] = @[]): ref ForumError = + new(result) + result.msg = message + result.data = + PostError( + errorFields: fields, + message: message + ) + var docConfig: StringTableRef docConfig = rstgen.defaultConfig() @@ -166,84 +179,3 @@ proc rstToHtml*(content: string): string = add(result, node, indWidth=0, addNewLines=false) except: echo("[WARNING] Could not parse rst html.") - -proc sendMail(config: Config, subject, message, recipient: string, from_addr = "forum@nim-lang.org", otherHeaders:seq[(string, string)] = @[]) {.async.} = - if config.smtpAddress.len == 0: - echo("[WARNING] Cannot send mail: no smtp server configured (smtpAddress).") - return - - var client = newAsyncSmtp() - await client.connect(config.smtpAddress, Port(config.smtpPort)) - if config.smtpUser.len > 0: - await client.auth(config.smtpUser, config.smtpPassword) - - let toList = @[recipient] - - var headers = otherHeaders - headers.add(("From", from_addr)) - - let encoded = createMessage(subject, message, - toList, @[], headers) - - await client.sendMail(from_addr, toList, $encoded) - -proc sendMailToMailingList*(config: Config, username, user_email_addr, subject, message: string, threadUrl: string, thread_id=0, post_id=0, is_reply=false) {.async.} = - # send message to a mailing list - if config.mlistAddress.len == 0: - echo("[WARNING] Cannot send mail: no mlistAddress configured.") - return - - let from_addr = "$# <$#>" % [username, user_email_addr] - - let date = getTime().getGMTime().format("ddd, d MMM yyyy HH:mm:ss") & " +0000" - var otherHeaders = @[ - ("Date", date), - ("Resent-From", "forum@nim-lang.org"), - ("Resent-date", date) - ] - - if is_reply: - let msg_id = "" % [$thread_id, $post_id] - otherHeaders.add(("Message-ID", msg_id)) - let references = "" % [$thread_id] - otherHeaders.add(("References", references)) - - else: # New thread - let msg_id = "" % $thread_id - otherHeaders.add(("Message-ID", msg_id)) - - var processedMsg: string - try: - processedMsg = rstToHTML(message) & "
View thread on Nim forum" - otherHeaders.add(("Content-Type", "text/html; charset=\"UTF-8\"")) - except: - processedMsg = message - - await sendMail(config, subject, processedMsg, config.mlistAddress, from_addr=from_addr, otherHeaders=otherHeaders) - -proc sendPassReset*(config: Config, email, user, resetUrl: string) {.async.} = - let message = """Hello $1, -A password reset has been requested for your account on the Nim Forum. - -If you did not make this request, you can safely ignore this email. -A password reset request can be made by anyone, and it does not indicate -that your account is in any danger of being accessed by someone else. - -If you do actually want to reset your password, visit this link: - - $2 - -Thank you for being a part of the Nim community!""" % [user, resetUrl] - await sendMail(config, "Nim Forum Password Recovery", message, email) - -proc sendEmailActivation*(config: Config, email, user, activateUrl: string) {.async.} = - let message = """Hello $1, -You have recently registered an account on the Nim Forum. - -As the final step in your registration, we require that you confirm your email -via the following link: - - $2 - -Thank you for registering and becoming a part of the Nim community!""" % [user, activateUrl] - await sendMail(config, "Nim Forum Account Email Confirmation", message, email)