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 ## If ``epoch`` is smaller than the epoch of the user's last login then
## the link is invalid. ## the link is invalid.
## The ``secret`` is the 'salt' field in the ``person`` table. ## The ``secret`` is the 'salt' field in the ``person`` table.
echo(user, password, epoch, secret)
when defined(windows): when defined(windows):
result = getMD5(user & password & epoch & secret) result = getMD5(user & password & epoch & secret)
else: 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 import
os, strutils, times, md5, strtabs, math, db_sqlite, os, strutils, times, md5, strtabs, math, db_sqlite,
scgi, jester, asyncdispatch, asyncnet, sequtils, 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 cgi except setCookie
import options import options
import sass import sass
import auth import auth, email, utils
import frontend/threadlist except User import frontend/threadlist except User
import frontend/[ import frontend/[
@ -63,24 +64,12 @@ type
noPagenumumNav: bool noPagenumumNav: bool
config: Config config: Config
ForumError = object of Exception
data: PostError
var var
db: DbConn db: DbConn
isFTSAvailable: bool isFTSAvailable: bool
config: Config config: Config
captcha: ReCaptcha captcha: ReCaptcha
mailer: Mailer
proc newForumError(message: string,
fields: seq[string] = @[]): ref ForumError =
new(result)
result.msg = message
result.data =
PostError(
errorFields: fields,
message: message
)
proc init(c: TForumData) = proc init(c: TForumData) =
c.userPass = "" c.userPass = ""
@ -131,42 +120,23 @@ proc setError(c: TForumData, field, msg: string): bool {.inline.} =
c.errorMsg = "Error: " & msg c.errorMsg = "Error: " & msg
return false return false
proc resetPassword(c: TForumData, nick, antibot, userIp: string): Future[bool] {.async.} = proc resetPassword(
# captcha validation: c: TForumData,
if config.recaptchaSecretKey.len > 0: email: string
var captchaValid: bool = false ) {.async.} =
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!")
# Gather some extra information to determine ident hash. # Gather some extra information to determine ident hash.
let epoch = $int(epochTime())
let row = db.getRow( 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] == "": if row[0] == "":
return setError(c, "nick", "Nickname not found") raise newForumError("Email not found", @["email"])
# 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")
return true await sendSecureEmail(
mailer,
ResetPassword, c.req,
row[0], row[1], row[2], row[3]
)
proc logout(c: TForumData) = proc logout(c: TForumData) =
const query = sql"delete from session where ip = ? and password = ?" const query = sql"delete from session where ip = ? and password = ?"
@ -279,6 +249,8 @@ proc initialise() =
doAssert config.isDev, "Recaptcha required for production!" doAssert config.isDev, "Recaptcha required for production!"
echo("[WARNING] No recaptcha secret key specified.") echo("[WARNING] No recaptcha secret key specified.")
mailer = newMailer(config)
db = open(connection=config.dbPath, user="", password="", db = open(connection=config.dbPath, user="", password="",
database="nimforum") database="nimforum")
isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & 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") 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, proc executeRegister(c: TForumData, name, pass, antibot, userIp,
email: string): Future[string] {.async.} = email: string): Future[string] {.async.} =
## Registers a new user and returns a new session key for that user's ## 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) let password = makePassword(pass, salt)
# Send activation email. # 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 # Add account to person table
exec(db, sql""" exec(db, sql"""
@ -726,7 +687,9 @@ proc updateProfile(
if rank != EmailUnconfirmed: if rank != EmailUnconfirmed:
raise newForumError("Rank needs a change when setting new email.") 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( exec(
db, db,
@ -1193,7 +1156,7 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
post re"/deleteUser": post "/deleteUser":
createTFD() createTFD()
if not c.loggedIn(): if not c.loggedIn():
let err = PostError( let err = PostError(
@ -1213,7 +1176,7 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
post re"/saveProfile": post "/saveProfile":
createTFD() createTFD()
if not c.loggedIn(): if not c.loggedIn():
let err = PostError( let err = PostError(
@ -1238,6 +1201,25 @@ routes:
let exc = (ref ForumError)(getCurrentException()) let exc = (ref ForumError)(getCurrentException())
resp Http400, $(%exc.data), "application/json" 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": get "/t/@id":
cond "id" in request.params cond "id" in request.params
@ -1342,16 +1324,6 @@ routes:
# else: # else:
# resp genMain(c, "Invalid ident hash", "Nim Forum") # 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?": post "/search/?@page?":
cond isFTSAvailable cond isFTSAvailable
createTFD() createTFD()

View file

@ -7,7 +7,7 @@ from times import getTime, getGMTime, format
let let
UsernameIdent* = IdentChars # TODO: Double check that everyone follows this. UsernameIdent* = IdentChars # TODO: Double check that everyone follows this.
import frontend/karaxutils import frontend/[karaxutils, error]
export parseInt export parseInt
proc `%`*[T](opt: Option[T]): JsonNode = proc `%`*[T](opt: Option[T]): JsonNode =
@ -17,11 +17,11 @@ proc `%`*[T](opt: Option[T]): JsonNode =
type type
Config* = object Config* = object
smtpAddress: string smtpAddress*: string
smtpPort: int smtpPort*: int
smtpUser: string smtpUser*: string
smtpPassword: string smtpPassword*: string
mlistAddress: string mlistAddress*: string
recaptchaSecretKey*: string recaptchaSecretKey*: string
recaptchaSiteKey*: string recaptchaSiteKey*: string
isDev*: bool isDev*: bool
@ -29,6 +29,19 @@ type
hostname*: string hostname*: string
name*: 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 var docConfig: StringTableRef
docConfig = rstgen.defaultConfig() docConfig = rstgen.defaultConfig()
@ -166,84 +179,3 @@ proc rstToHtml*(content: string): string =
add(result, node, indWidth=0, addNewLines=false) add(result, node, indWidth=0, addNewLines=false)
except: except:
echo("[WARNING] Could not parse rst html.") 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)