Email activation and password resets for users.

This commit is contained in:
Dominik Picheta 2015-05-28 01:36:31 +01:00
commit 05a529df5b
4 changed files with 224 additions and 20 deletions

View file

@ -367,3 +367,33 @@
# end if # end if
#end proc #end proc
# #
#
#proc genFormResetPassword(c: var TForumData): string =
# result = ""
<div id="talk-head">
<div class="info-post">
<div>
<a href="${c.req.makeUri("/")}"><b>forum index</b></a> &gt;
Reset Password
</div>
</div>
</div>
<form action="${c.req.makeUri("/doresetpassword", false)}" method="POST">
<table border="0">
<tr>
<td>${FieldValid(c, "nick", "Your nickname:")}</td>
<td><input type="text" name="nick" maxlength="20" /></td>
</tr>
<tr>
<td>${FieldValid(c, "antibot", "What is " & antibot(c) & "?")}</td>
<td>${TextWidget(c, "antibot", "", maxlength=4)}</td>
</tr>
</table>
#if c.errorMsg != "":
<div style="float: left; width: 100%;">
<span class="error">$c.errorMsg</span>
</div>
#end if
<input type="submit" value="Email me">
</form>
#end proc

187
forum.nim
View file

@ -8,12 +8,13 @@
import import
os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers, os, strutils, times, md5, strtabs, cgi, math, db_sqlite, matchers,
rst, rstgen, captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils rst, rstgen, captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils,
parseutils, utils
when not defined(windows): when not defined(windows):
import bcrypt # TODO import bcrypt # TODO
from htmlgen import tr, th, td, span from htmlgen import tr, th, td, span, input
const const
unselectedThread = -1 unselectedThread = -1
@ -25,6 +26,7 @@ const
noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"] noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"]
noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"] noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"]
banReasonDeactivated = "DEACTIVATED" banReasonDeactivated = "DEACTIVATED"
banReasonEmailUnconfirmed = "EMAILCONFIRMATION"
type type
TCrud = enum crCreate, crRead, crUpdate, crDelete TCrud = enum crCreate, crRead, crUpdate, crDelete
@ -50,6 +52,7 @@ type
totalPosts: int totalPosts: int
search: string search: string
noPagenumumNav: bool noPagenumumNav: bool
config: Config
TStyledButton = tuple[text: string, link: string] TStyledButton = tuple[text: string, link: string]
@ -72,6 +75,7 @@ var
db: TDbConn db: TDbConn
docConfig: StringTableRef docConfig: StringTableRef
isFTSAvailable: bool isFTSAvailable: bool
config: Config
proc init(c: var TForumData) = proc init(c: var TForumData) =
c.userPass = "" c.userPass = ""
@ -246,6 +250,20 @@ proc makePassword(password, salt: string, comparingTo = ""): string =
let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8) let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8)
result = hash(getMD5(salt & getMD5(password)), bcryptSalt) result = hash(getMD5(salt & getMD5(password)), bcryptSalt)
proc makeIdentHash(user, password, epoch, secret: string,
comparingTo = ""): string =
## Creates a hash verifying the identity of a user. Used for password reset
## links and email activation links.
## 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:
let bcryptSalt = if comparingTo != "": comparingTo else: genSalt(8)
result = hash(user & password & epoch & secret, bcryptSalt)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
template `||`(x: expr): expr = (if not isNil(x): x else: "") template `||`(x: expr): expr = (if not isNil(x): x else: "")
@ -274,7 +292,14 @@ proc setError(c: var TForumData, field, msg: string): bool {.inline.} =
c.errorMsg = "Error: " & msg c.errorMsg = "Error: " & msg
return false return false
proc register(c: var TForumData, name, pass, antibot, email: string): bool = proc isCaptchaCorrect(c: var TForumData, antibot: string): bool =
## Determines whether the user typed in the captcha correctly.
let correctRes = getValue(db,
sql"select answer from antibot where ip = ?", c.req.ip)
return antibot == correctRes
proc register(c: var TForumData, name, pass, antibot,
email: string): bool =
# Username validation: # Username validation:
if name.len == 0 or not allCharsInSet(name, SecureChars): if name.len == 0 or not allCharsInSet(name, SecureChars):
return setError(c, "name", "Invalid username!") return setError(c, "name", "Invalid username!")
@ -285,25 +310,68 @@ proc register(c: var TForumData, name, pass, antibot, email: string): bool =
if pass.len < 4: if pass.len < 4:
return setError(c, "new_password", "Invalid password!") return setError(c, "new_password", "Invalid password!")
# antibot validation: # captcha validation:
let correctRes = getValue(db, if not isCaptchaCorrect(c, antibot):
sql"select answer from antibot where ip = ?", c.req.ip) return setError(c, "antibot", "Answer to captcha incorrect!")
if antibot != correctRes:
return setError(c, "antibot", "You seem to be a bot!")
# email validation # email validation
if not validEmailAddress(email): if not ('@' in email and '.' in email):
return setError(c, "email", "Invalid email address") return setError(c, "email", "Invalid email address")
# perform registration: # perform registration:
var salt = makeSalt() var salt = makeSalt()
let password = makePassword(pass, salt)
# Send activation email.
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)
# Block until we send the email.
# 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")
# add account to person table
exec(db, exec(db,
sql("INSERT INTO person(name, password, email, salt, status, lastOnline, " & sql("INSERT INTO person(name, password, email, salt, status, lastOnline, " &
"ban) VALUES (?, ?, ?, ?, 'user', DATETIME('now'), '')"), name, "ban) VALUES (?, ?, ?, ?, 'user', DATETIME('now'), ?)"), name,
makePassword(pass, salt), email, salt) password, email, salt,
# return setError(c, "", "Could not create your account!") banReasonEmailUnconfirmed)
return true return true
proc resetPassword(c: var TForumData, nick, antibot: string): bool =
# Validate captcha
if not isCaptchaCorrect(c, antibot):
return setError(c, "antibot", "Answer to captcha incorrect!")
# 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)
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")
proc checkLoggedIn(c: var TForumData) = proc checkLoggedIn(c: var TForumData) =
let pass = c.req.cookies["sid"] let pass = c.req.cookies["sid"]
if pass.len == 0: return if pass.len == 0: return
@ -519,6 +587,15 @@ proc login(c: var TForumData, name, pass: string): bool =
else: else:
return c.setError("password", "Login failed!") return c.setError("password", "Login failed!")
proc verifyIdentHash(c: var TForumData, name, epoch, ident: string): bool =
const query =
sql"select password, salt, strftime('%s', lastOnline) from person where name = ?"
var row = getRow(db, query, name)
if row[0] == "": return false
let newIdent = makeIdentHash(name, row[0], epoch, row[1], ident)
if row[2].parseInt > epoch.parseInt: return false
result = newIdent == ident
proc setBan(c: var TForumData, nick, reason: string): bool = proc setBan(c: var TForumData, nick, reason: string): bool =
const query = const query =
sql("update person set ban = ? where name = ?") sql("update person set ban = ? where name = ?")
@ -762,6 +839,8 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string =
td(case ui.ban td(case ui.ban
of banReasonDeactivated: of banReasonDeactivated:
"Deactivated" "Deactivated"
of banReasonEmailUnconfirmed:
"Awaiting email confirmation"
of "": of "":
"Active" "Active"
else: else:
@ -789,6 +868,9 @@ proc genProfile(c: var TForumData, ui: TUserInfo): string =
elif ui.ban == banReasonDeactivated: elif ui.ban == banReasonDeactivated:
htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"), htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"),
"Activate user") "Activate user")
elif ui.ban == banReasonEmailUnconfirmed:
htmlgen.a(href=c.genSetUserStatusUrl(ui.nick, "activate"),
"Confirm user's email")
else: "" else: ""
else: "") else: "")
) )
@ -814,6 +896,7 @@ template createTFD(): stmt =
c.startTime = epochTime() c.startTime = epochTime()
c.isThreadsList = false c.isThreadsList = false
c.pageNum = 1 c.pageNum = 1
c.config = config
if request.cookies.len > 0: if request.cookies.len > 0:
checkLoggedIn(c) checkLoggedIn(c)
@ -950,8 +1033,9 @@ routes:
post "/doregister": post "/doregister":
createTFD() createTFD()
if c.register(@"name", @"new_password", @"antibot", @"email"): if c.register(@"name", @"new_password", @"antibot", @"email"):
discard c.login(@"name", @"new_password") resp genMain(c, "You are now registered. You must now confirm your" &
finishLogin() " email address by clicking the link sent to " & @"email",
"Registration successful - Nim Forum")
else: else:
resp c.genMain(genFormRegister(c)) resp c.genMain(genFormRegister(c))
@ -1060,6 +1144,80 @@ routes:
else: else:
resp genMain(c, "Failure", "Nim Forum") resp genMain(c, "Failure", "Nim Forum")
get "/activateEmail/?":
createTFD()
cond (@"nick" != "")
cond (@"epoch" != "")
cond (@"ident" != "")
var epoch: BiggestInt = 0
cond(parseBiggestInt(@"epoch", epoch) > 0)
var success = false
if verifyIdentHash(c, @"nick", $epoch, @"ident"):
let ban = db.getValue(sql"select ban from person where name = ?", @"nick")
if ban == banReasonEmailUnconfirmed:
success = setBan(c, @"nick", "")
if success:
resp genMain(c, "Account activated", "Nim Forum")
else:
resp genMain(c, "Account activation failed", "Nim Forum")
get "/emailResetPassword/?":
createTFD()
cond (@"nick" != "")
cond (@"epoch" != "")
cond (@"ident" != "")
var epoch: BiggestInt = 0
cond(parseBiggestInt(@"epoch", epoch) > 0)
if verifyIdentHash(c, @"nick", $epoch, @"ident"):
let formBody = input(`type`="hidden", name="nick", value = @"nick") &
input(`type`="hidden", name="epoch", value = @"epoch") &
input(`type`="hidden", name="ident", value = @"ident") &
input(`type`="password", name="password") &
"<br/>" &
input(`type`="submit", name="submitBtn",
value="Change my password")
let message = htmlgen.p("Please enter a new password for ",
htmlgen.b(@"nick"), ':')
let content = htmlgen.form(action=c.req.makeUri("/doemailresetpassword"),
`method`="POST", message & formBody)
resp genMain(c, content, "Reset password - Nim Forum")
else:
resp genMain(c, "Invalid ident hash", "Error - Nim Forum")
post "/doemailresetpassword":
createTFD()
cond (@"nick" != "")
cond (@"epoch" != "")
cond (@"ident" != "")
cond (@"password" != "")
var epoch: BiggestInt = 0
cond(parseBiggestInt(@"epoch", epoch) > 0)
if verifyIdentHash(c, @"nick", $epoch, @"ident"):
let res = setPassword(c, @"nick", @"password")
if res:
resp genMain(c, "Password reset successfully!", "Nim Forum")
else:
resp genMain(c, "Password reset failure", "Nim Forum")
else:
resp genMain(c, "Invalid ident hash", "Nim Forum")
get "/resetPassword/?":
createTFD()
resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum")
post "/doresetpassword":
createTFD()
echo(request.params)
cond (@"nick" != "")
if resetPassword(c, @"nick", @"antibot"):
resp genMain(c, "Email sent!", "Reset Password - Nim Forum")
else:
resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum")
const licenseRst = slurp("static/license.rst") const licenseRst = slurp("static/license.rst")
get "/license": get "/license":
createTFD() createTFD()
@ -1117,6 +1275,7 @@ when isMainModule:
database="nimforum") database="nimforum")
isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " &
"type='table' AND name='post_fts'")).len == 1 "type='table' AND name='post_fts'")).len == 1
config = loadConfig()
var http = true var http = true
if paramCount() > 0: if paramCount() > 0:
if paramStr(1) == "scgi": if paramStr(1) == "scgi":

View file

@ -117,6 +117,7 @@
<input type="submit" style="display: none;" <input type="submit" style="display: none;"
id="hdnLogin" value="Login" /> id="hdnLogin" value="Login" />
</form> </form>
<a id="passreset" href=${c.req.makeUri("/resetPassword")}>Reset password</a>
#if c.errorMsg != "" and c.req.pathInfo.normalizeUri == "/dologin": #if c.errorMsg != "" and c.req.pathInfo.normalizeUri == "/dologin":
<span class="error">$c.errorMsg</span> <span class="error">$c.errorMsg</span>
#end if #end if

View file

@ -516,6 +516,20 @@ div#sidebar .content .search
width: 95%; width: 95%;
} }
div#sidebar .content a#passreset {
color: #CEDAE9;
font-size: 9pt;
display: block;
text-decoration: none;
margin-top: -4pt;
}
div#sidebar .content a#passreset:hover {
color: #fff;
}
span.error span.error
{ {
float: left; float: left;