Implements /resetPassword and refactors email code.

This commit is contained in:
Dominik Picheta 2018-05-20 15:03:56 +01:00
commit 249031b9a8
4 changed files with 199 additions and 162 deletions

View file

@ -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:

134
src/email.nim Normal file
View file

@ -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"])

View file

@ -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()

View file

@ -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 = "<forum-id-$#-$#@nim-lang.org>" % [$thread_id, $post_id]
otherHeaders.add(("Message-ID", msg_id))
let references = "<forum-tid-$#@nim-lang.org>" % [$thread_id]
otherHeaders.add(("References", references))
else: # New thread
let msg_id = "<forum-tid-$#@nim-lang.org>" % $thread_id
otherHeaders.add(("Message-ID", msg_id))
var processedMsg: string
try:
processedMsg = rstToHTML(message) & "<hr/><a href=\"" & threadUrl & "\" style=\"font-size:small\">View thread on Nim forum</a>"
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)