added the E-mail address validation form

This commit is contained in:
2025-10-04 15:13:00 -06:00
parent c7f5c57e82
commit 070afc365e
7 changed files with 242 additions and 3 deletions
+36
View File
@@ -92,6 +92,42 @@ func (u *User) NewAuthToken() (string, error) {
return fmt.Sprintf("AQAT:%d|%s|%d|", u.Uid, newToken, checkValue), nil return fmt.Sprintf("AQAT:%d|%s|%d|", u.Uid, newToken, checkValue), nil
} }
/* ConfirmEMailAddress checks the E-mail confirmation number and sets "verified" status if it's OK.
* Parameters:
* confnum - The entered confirmation number.
* remoteIP - The remote IP address for audit messages.
* Returns:
* Standard Go error status.
*/
func (u *User) ConfirmEMailAddress(confnum int32, remoteIP string) error {
var ar *AuditRecord = nil
defer func() {
AmStoreAudit(ar)
}()
log.Debugf("ConfirmEMailAddress for UID %d", u.Uid)
u.Mutex.Lock()
defer u.Mutex.Unlock()
if u.VerifyEMail || AmTestPermission("Global.NoEmailVerify", u.BaseLevel) {
log.Debug("...user has either already confirmed or is exempt")
return nil
}
if confnum != u.EmailConfNum {
log.Warn("...confirmation number incorrect")
ar = AmNewAudit(AuditVerifyEmailFail, u.Uid, remoteIP, "Invalid confirmation number")
return errors.New("confirmation number is incorrect. Please try again")
}
_, err := amdb.Exec("UPDATE users SET verify_email = 1, base_lvl = ? WHERE uid = ?",
AmDefaultRole("Global.AfterVerify").Level(), u.Uid)
if err == nil {
u.VerifyEMail = true
u.BaseLevel = AmDefaultRole("Global.AfterVerify").Level()
// TODO: auto-join communities if necessary
ar = AmNewAudit(AuditVerifyEmailOK, u.Uid, remoteIP)
}
return err
}
/* AmGetUser returns a reference to the specified user. /* AmGetUser returns a reference to the specified user.
* Parameters: * Parameters:
* uid - The UID of the user. * uid - The UID of the user.
+100 -2
View File
@@ -12,6 +12,7 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/url"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/email"
@@ -118,8 +119,11 @@ func Login(ctxt ui.AmContext) (string, any, error) {
log.Errorf("unable to generate auth string for uid %d: %v", user.Uid, cerr) log.Errorf("unable to generate auth string for uid %d: %v", user.Uid, cerr)
} }
} }
// TODO: bounce to E-mail verify if we can do so if user.VerifyEMail {
return "redirect", target, nil return "redirect", target, nil
} else {
return "redirect", "/verify?tgt=" + url.PathEscape(target), nil
}
} }
dlg.Field("pass").Value = "" dlg.Field("pass").Value = ""
return dlg.RenderError(ctxt, "No known button click on POST to login function.") return dlg.RenderError(ctxt, "No known button click on POST to login function.")
@@ -149,6 +153,100 @@ func Logout(ctxt ui.AmContext) (string, any, error) {
return "redirect", target, nil return "redirect", target, nil
} }
/* VerifyEmailForm renders the E-mail address verification form.
* Parameters:
* ctxt - The AmContext for the request.
* Returns:
* Command string dictating what to be rendered.
* Data as a parameter for the command string.
* Standard Go error status.
*/
func VerifyEmailForm(ctxt ui.AmContext) (string, any, error) {
// Get target URI.
target := ctxt.Parameter("tgt")
if target == "" {
target = "/"
}
// If user is not logged in, this is an error.
user := ctxt.CurrentUser()
if !user.IsAnon {
return ui.ErrorPage(ctxt, errors.New("you must log in before you can verify your account's E-mail address"))
}
// If user is already verified, this is a no-op.
if user.VerifyEMail {
return "redirect", target, nil
}
dlg, err := ui.AmLoadDialog("verify_email")
if err == nil {
dlg.Field("tgt").Value = target
return dlg.Render(ctxt)
}
return ui.ErrorPage(ctxt, err)
}
func VerifyEMail(ctxt ui.AmContext) (string, any, error) {
// If user is not logged in, this is an error.
user := ctxt.CurrentUser()
if !user.IsAnon {
return ui.ErrorPage(ctxt, errors.New("you must log in before you can verify your account's E-mail address"))
}
dlg, err := ui.AmLoadDialog("verify_email")
if err == nil {
dlg.LoadFromForm(ctxt)
target := dlg.Field("tgt").Value
if target == "" {
target = "/"
}
// If user is already verified, this is a no-op.
if user.VerifyEMail {
return "redirect", target, nil
}
action := dlg.WhichButton(ctxt)
if action == "cancel" { // Cancel button pressed
return "redirect", target, nil
}
if action == "sendagain" {
var ci *database.ContactInfo
ci, err = user.ContactInfo()
if err == nil {
if ci != nil && ci.Email != nil && *ci.Email != "" {
msg := email.AmNewEmailMessage(user.Uid, ctxt.RemoteIP())
msg.AddTo(*ci.Email, "")
msg.SetTemplate("verify_email.jet")
msg.AddVariable("username", user.Username)
msg.AddVariable("confnum", user.EmailConfNum)
msg.Send()
} else {
err = errors.New("cannot find email address")
}
}
if err == nil {
return dlg.RenderInfo(ctxt, "Verification message has been sent to your E-mail address.")
} else {
return dlg.RenderError(ctxt, err.Error())
}
}
if action == "ok" {
err = dlg.Validate()
if err == nil {
err = user.ConfirmEMailAddress(dlg.Field("num").AuxData.(int32), ctxt.RemoteIP())
if err == nil {
return "redirect", target, nil
}
}
return dlg.RenderError(ctxt, err.Error())
}
return dlg.RenderError(ctxt, "No known button click on POST to verify function.")
}
return ui.ErrorPage(ctxt, err)
}
/* NewAccountUserAgreement renders the Amsterdam user agreement for new accounts. /* NewAccountUserAgreement renders the Amsterdam user agreement for new accounts.
* Parameters: * Parameters:
* ctxt - The AmContext for the request. * ctxt - The AmContext for the request.
@@ -166,7 +264,7 @@ func NewAccountUserAgreement(ctxt ui.AmContext) (string, any, error) {
// If user is already logged in, this is an error. // If user is already logged in, this is an error.
if !ctxt.CurrentUser().IsAnon { if !ctxt.CurrentUser().IsAnon {
return ui.ErrorPage(ctxt, fmt.Errorf("you cannot create a new account while logged in on an existing one. You must log out first")) return ui.ErrorPage(ctxt, errors.New("you cannot create a new account while logged in on an existing one. You must log out first"))
} }
ctxt.VarMap().Set("target", target) ctxt.VarMap().Set("target", target)
+2
View File
@@ -48,6 +48,8 @@ func setupEcho() *echo.Echo {
e.GET("/logout", ui.AmWrap(Logout)) e.GET("/logout", ui.AmWrap(Logout))
e.GET("/newacct", ui.AmWrap(NewAccountUserAgreement)) e.GET("/newacct", ui.AmWrap(NewAccountUserAgreement))
e.GET("/newacct2", ui.AmWrap(NewAccountForm)) e.GET("/newacct2", ui.AmWrap(NewAccountForm))
e.GET("/verify", ui.AmWrap(VerifyEmailForm))
e.POST("/verify", ui.AmWrap(VerifyEMail))
return e return e
} }
+42
View File
@@ -14,6 +14,8 @@ import (
"embed" "embed"
"fmt" "fmt"
"net/mail" "net/mail"
"strconv"
"strings"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -95,6 +97,17 @@ func (fld *DialogItem) IsChecked() bool {
return false return false
} }
// ValueRange returns the minimum and maximum values for an integer field.
func (fld *DialogItem) ValueRange() (int, int) {
if fld.Type == "integer" && fld.Param != "" {
parms := strings.Split(fld.Param, "-")
low, _ := strconv.Atoi(parms[0])
high, _ := strconv.Atoi(parms[1])
return low, high
}
return -1, -1
}
/* Field returns a pointer to a dialog's field, given its name. /* Field returns a pointer to a dialog's field, given its name.
* Parameters: * Parameters:
* name - The name of the field to find. * name - The name of the field to find.
@@ -259,6 +272,34 @@ func validateTextField(fld *DialogItem) error {
return nil return nil
} }
/* validateIntegerField validates an integer field.
* Parameters:
* fld - The field to be validated.
* Returns:
* Standard Go error status.
*/
func validateIntegerField(fld *DialogItem) error {
err := validateTextField(fld)
if err == nil {
var v int
v, err = strconv.Atoi(fld.Value)
if err == nil {
fld.AuxData = v // cache parsed value
lo, hi := fld.ValueRange()
if lo != -1 && hi != -1 {
if v < lo {
return fmt.Errorf("value of field \"%s\" cannot be less than %d", fld.Caption, lo)
} else if v > hi {
return fmt.Errorf("value of field \"%s\" cannot be greater than %d", fld.Caption, hi)
}
}
} else {
return fmt.Errorf("value of field \"%s\" is not a valid integer", fld.Caption)
}
}
return nil
}
/* validateAmsIdField validates an Amsterdam ID field. /* validateAmsIdField validates an Amsterdam ID field.
* Parameters: * Parameters:
* fld - The field to be validated. * fld - The field to be validated.
@@ -339,6 +380,7 @@ var validators = map[string]validatorFunc{
"email": validateEmailField, "email": validateEmailField,
"header": nilValidator, "header": nilValidator,
"hidden": nilValidator, "hidden": nilValidator,
"integer": validateIntegerField,
"password": validateTextField, "password": validateTextField,
"text": validateTextField, "text": validateTextField,
} }
+39
View File
@@ -0,0 +1,39 @@
#
# Amsterdam Web Communities System
# Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
#
name: "verify"
formName: "verifyform"
menuSelector: "top"
title: "Verify E-mail Address"
action: "/verify"
instructions: >
Check your E-mail, then enter the confirmation number that was E-mailed to you in the field below.
(The E-mail might take a few minutes or so to get to you, so be patient.) Once you do so, your account
will be fully validated. If you have not received your confirmation within a few minutes, click on the
<b>Send Again</b> button below.
fields:
- type: "hidden"
name: "tgt"
value: ""
- type: "integer"
name: "num"
caption: "Confirmation Number"
size: 7
param: "1000000-9999999"
- type: "button"
name: "ok"
caption: "OK"
param: "blue"
- type: "button"
name: "sendagain"
caption: "Send Again"
param: "gray"
- type: "button"
name: "cancel"
caption: "Cancel"
param: "red"
+6
View File
@@ -13,6 +13,7 @@ package ui
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/config"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
@@ -99,6 +100,11 @@ func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc {
} else { } else {
log.Warnf("unable to rotate login cookie: %v", cerr) log.Warnf("unable to rotate login cookie: %v", cerr)
} }
if !user.VerifyEMail {
// bounce to E-mail verification before we go anywhere
return sendPageData(ctxt, amctxt, "redirect",
"/verify?tgt="+url.PathEscape(ctxt.Request().URL.Path))
}
} else { } else {
log.Errorf("login cookie bogus, do not use: %v", cerr) log.Errorf("login cookie bogus, do not use: %v", cerr)
amctxt.ClearLoginCookie() amctxt.ClearLoginCookie()
+16
View File
@@ -67,6 +67,22 @@
value="{{ .Value }}" value="{{ .Value }}"
class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div> </div>
{{ else if .Type == "integer" }}
<div class="flex items-center">
<label for="{{ .Name }}" class="w-64 text-right pr-4 text-black text-sm">
{{ .Caption }}{{ if .Subcaption != "" }} {{ .Subcaption }}{{ end }}:
{{ if .Required }}<span class="text-red-600">*</span>{{ end }}
</label>
<input type="text" id="{{ .Name }}" name="{{ .Name }}"
{{ if .Size > 0 }}size="{{ .Size }}"{{ end }}
{{ if .MaxLength > 0 }}maxlength="{{ .MaxLength }}"{{ end }}
value="{{ .Value }}"
class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
{{ lo, hi := .ValueRange() }}
{{ if lo != -1 && hi != -1 }}
<span class="text-sm">({{ lo }}-{{ hi }})</span>
{{ end }}
</div>
{{ else if .Type == "password" }} {{ else if .Type == "password" }}
<div class="flex items-center"> <div class="flex items-center">
<label for="{{ .Name }}" class="w-64 text-right pr-4 text-black text-sm"> <label for="{{ .Name }}" class="w-64 text-right pr-4 text-black text-sm">