Certification: Secondary Photo ID Workflow

* Add an Admin Certification Photo workflow where we can request the user to
  upload a secondary form of ID (government issued photo ID showing their
  face and date of birth).
* An admin rejection option can request secondary photo ID.
* It sends a distinct e-mail to the user apart from the regular rejection email
* It flags their cert photo as "Secondary Needed" forever: even if the user
  removes their cert photo and starts from scratch, it will immediately request
  secondary ID when uploading a new primary photo.
* Secondary photos are deleted from the server on both Approve and Reject by
  the admin account, for user privacy.
* If approved, a Secondary Approved=true boolean is stored in the database. This
  boolean is set to False if the user deletes their cert photo in the future.
This commit is contained in:
Noah Petherbridge 2024-05-26 12:34:00 -07:00
parent af76c251c6
commit f0e69f78da
8 changed files with 320 additions and 32 deletions

View File

@ -72,6 +72,8 @@ func Certification() http.HandlerFunc {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// Are they deleting their photo? // Are they deleting their photo?
if r.PostFormValue("delete") == "true" { if r.PostFormValue("delete") == "true" {
// Primary cert photo
if cert.Filename != "" { if cert.Filename != "" {
if err := photo.Delete(cert.Filename); err != nil { if err := photo.Delete(cert.Filename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err) log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
@ -79,8 +81,17 @@ func Certification() http.HandlerFunc {
cert.Filename = "" cert.Filename = ""
} }
// Secondary cert photo
if cert.SecondaryFilename != "" {
if err := photo.Delete(cert.SecondaryFilename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
}
cert.Status = models.CertificationPhotoNeeded cert.Status = models.CertificationPhotoNeeded
cert.AdminComment = "" cert.AdminComment = ""
cert.SecondaryVerified = false
cert.Save() cert.Save()
// Removing your photo = not certified again. // Removing your photo = not certified again.
@ -102,6 +113,9 @@ func Certification() http.HandlerFunc {
return return
} }
// Is it their secondary form of ID being uploaded?
isSecondary := r.PostFormValue("secondary") == "true"
// Get the uploaded file. // Get the uploaded file.
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
@ -125,17 +139,38 @@ func Certification() http.HandlerFunc {
} }
// Are they replacing their old photo? // Are they replacing their old photo?
if cert.Filename != "" { if cert.Filename != "" && !isSecondary {
if err := photo.Delete(cert.Filename); err != nil { if err := photo.Delete(cert.Filename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err) log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
} }
} else if isSecondary && cert.SecondaryFilename != "" {
if err := photo.Delete(cert.SecondaryFilename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
} }
// Update their certification photo. // Update their certification photo.
cert.Status = models.CertificationPhotoPending cert.Status = models.CertificationPhotoPending
cert.Filename = filename if isSecondary {
cert.SecondaryFilename = filename
cert.SecondaryNeeded = true
cert.SecondaryVerified = false
} else {
cert.Filename = filename
}
cert.AdminComment = "" cert.AdminComment = ""
cert.IPAddress = utility.IPAddress(r) cert.IPAddress = utility.IPAddress(r)
// Secondary ID workflow: if the user
// 1. Uploads a regular cert photo
// 2. An admin marks secondary ID as needed
// 3. They remove everything and reupload a new cert photo, without a secondary ID attached
// Then we don't e-mail the admin for approval yet, and move straight to Secondary ID Requested
// for the user to upload their secondary ID now.
if cert.Status == models.CertificationPhotoPending && cert.SecondaryNeeded && cert.SecondaryFilename == "" {
cert.Status = models.CertificationPhotoSecondary
}
if err := cert.Save(); err != nil { if err := cert.Save(); err != nil {
session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err) session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
@ -154,16 +189,18 @@ func Certification() http.HandlerFunc {
} }
// Notify the admin email to check out this photo. // Notify the admin email to check out this photo.
if err := mail.Send(mail.Message{ if cert.Status == models.CertificationPhotoPending {
To: config.Current.AdminEmail, if err := mail.Send(mail.Message{
Subject: "New Certification Photo Needs Approval", To: config.Current.AdminEmail,
Template: "email/certification_admin.html", Subject: "New Certification Photo Needs Approval",
Data: map[string]interface{}{ Template: "email/certification_admin.html",
"User": currentUser, Data: map[string]interface{}{
"URL": config.Current.BaseURL + "/admin/photo/certification", "User": currentUser,
}, "URL": config.Current.BaseURL + "/admin/photo/certification",
}); err != nil { },
log.Error("Certification: failed to notify admins of pending photo: %s", err) }); err != nil {
log.Error("Certification: failed to notify admins of pending photo: %s", err)
}
} }
// Log the change. Note the original IP and GeoIP insights - we once saw a spammer upload // Log the change. Note the original IP and GeoIP insights - we once saw a spammer upload
@ -319,9 +356,20 @@ func AdminCertification() http.HandlerFunc {
} else { } else {
cert.Status = models.CertificationPhotoRejected cert.Status = models.CertificationPhotoRejected
cert.AdminComment = comment cert.AdminComment = comment
if comment == "(ignore)" { if comment == "(ignore)" || comment == "(secondary)" {
cert.AdminComment = "" cert.AdminComment = ""
} }
// With a secondary photo ID? Remove the photo ID immediately.
if cert.SecondaryFilename != "" {
// Delete it immediately.
if err := photo.Delete(cert.SecondaryFilename); err != nil {
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
cert.SecondaryVerified = false
}
if err := cert.Save(); err != nil { if err := cert.Save(); err != nil {
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err) session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
@ -347,6 +395,46 @@ func AdminCertification() http.HandlerFunc {
return return
} }
// Secondary verification required: the user will be asked to upload a blacked-out
// photo ID to be certified again.
if comment == "(secondary)" {
cert.Status = models.CertificationPhotoSecondary
cert.SecondaryNeeded = true
cert.SecondaryVerified = false
if err := cert.Save(); err != nil {
log.Error("Error saving cert photo: %s", err)
}
// Notify the user about this rejection.
notif := &models.Notification{
UserID: user.ID,
AboutUser: *user,
Type: models.NotificationCertSecondary,
Message: "A secondary form of photo ID is requested. Please [click here](/photo/certification) to learn more.",
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create rejection notification: %s", err)
}
// Notify the user via email.
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Regarding your nonshy certification photo",
Template: "email/certification_secondary.html",
Data: map[string]interface{}{
"Username": user.Username,
"AdminComment": comment,
"URL": config.Current.BaseURL + "/photo/certification",
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
}
session.Flash(w, r, "The user will be asked to provide a secondary form of ID.")
templates.Redirect(w, r.URL.Path)
return
}
// Notify the user about this rejection. // Notify the user about this rejection.
notif := &models.Notification{ notif := &models.Notification{
UserID: user.ID, UserID: user.ID,
@ -377,6 +465,17 @@ func AdminCertification() http.HandlerFunc {
case "approve": case "approve":
cert.Status = models.CertificationPhotoApproved cert.Status = models.CertificationPhotoApproved
cert.AdminComment = "" cert.AdminComment = ""
// With a secondary photo ID?
if cert.SecondaryFilename != "" {
// Delete it immediately.
if err := photo.Delete(cert.SecondaryFilename); err != nil {
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
cert.SecondaryVerified = true
}
if err := cert.Save(); err != nil { if err := cert.Save(); err != nil {
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err) session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)

View File

@ -8,15 +8,18 @@ import (
// CertificationPhoto table. // CertificationPhoto table.
type CertificationPhoto struct { type CertificationPhoto struct {
ID uint64 `gorm:"primaryKey"` ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"uniqueIndex"` UserID uint64 `gorm:"uniqueIndex"`
Filename string Filename string
Filesize int64 Filesize int64
Status CertificationPhotoStatus Status CertificationPhotoStatus
AdminComment string AdminComment string
IPAddress string // the IP they uploaded the photo from SecondaryNeeded bool // a secondary form of ID has been requested
CreatedAt time.Time SecondaryFilename string // photo ID upload
UpdatedAt time.Time SecondaryVerified bool // mark true when ID checked so original can be deleted
IPAddress string // the IP they uploaded the photo from
CreatedAt time.Time
UpdatedAt time.Time
} }
type CertificationPhotoStatus string type CertificationPhotoStatus string
@ -26,6 +29,10 @@ const (
CertificationPhotoPending CertificationPhotoStatus = "pending" CertificationPhotoPending CertificationPhotoStatus = "pending"
CertificationPhotoApproved CertificationPhotoStatus = "approved" CertificationPhotoApproved CertificationPhotoStatus = "approved"
CertificationPhotoRejected CertificationPhotoStatus = "rejected" CertificationPhotoRejected CertificationPhotoStatus = "rejected"
// If a photo is pending approval but the admin wants to engage the
// secondary check (prompt user for a photo ID upload)
CertificationPhotoSecondary CertificationPhotoStatus = "secondary"
) )
// GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record. // GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record.

View File

@ -41,6 +41,7 @@ const (
NotificationAlsoCommented NotificationType = "also_comment" NotificationAlsoCommented NotificationType = "also_comment"
NotificationAlsoPosted NotificationType = "also_posted" // forum replies NotificationAlsoPosted NotificationType = "also_posted" // forum replies
NotificationCertRejected NotificationType = "cert_rejected" NotificationCertRejected NotificationType = "cert_rejected"
NotificationCertSecondary NotificationType = "cert_secondary" // secondary cert photo requested
NotificationCertApproved NotificationType = "cert_approved" NotificationCertApproved NotificationType = "cert_approved"
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
NotificationNewPhoto NotificationType = "new_photo" NotificationNewPhoto NotificationType = "new_photo"

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -582,6 +582,11 @@
<span> <span>
Your <strong>certification photo</strong> was approved! Your <strong>certification photo</strong> was approved!
</span> </span>
{{else if eq .Type "cert_secondary"}}
<span class="icon"><i class="fa fa-certificate has-text-warning"></i></span>
<span>
About your <strong>certification photo:</strong>
</span>
{{else if eq .Type "cert_rejected"}} {{else if eq .Type "cert_rejected"}}
<span class="icon"><i class="fa fa-certificate has-text-danger"></i></span> <span class="icon"><i class="fa fa-certificate has-text-danger"></i></span>
<span> <span>

View File

@ -117,10 +117,30 @@
{{else}} {{else}}
<strong>{{.Status}}</strong> <strong>{{.Status}}</strong>
{{end}} {{end}}
<!-- Secondary approved? -->
{{if .SecondaryVerified}}
<strong class="has-text-info">
<i class="fa fa-id-card ml-3 mr-1"></i> ID Verified
</strong>
{{end}}
</div> </div>
</div> </div>
</div> </div>
<!-- Secondary photo ID attached? -->
{{if .SecondaryFilename}}
<div class="block">
<div class="mb-2">
<strong class="has-text-success">
<i class="fa fa-id-card"></i>
Secondary Photo ID Attached
</strong>
</div>
<img src="{{PhotoURL .SecondaryFilename}}">
</div>
{{end}}
<div class="block"> <div class="block">
<strong><i class="fa fa-location-dot mr-1"></i> GeoIP Insights:</strong> <strong><i class="fa fa-location-dot mr-1"></i> GeoIP Insights:</strong>
<div> <div>
@ -136,6 +156,15 @@
</div> </div>
</div> </div>
<!-- Secondary ID was needed in the past for this user -->
{{if .SecondaryNeeded}}
<div class="block is-size-7 has-text-warning">
<i class="fa fa-id-card"></i> A secondary form of ID was requested from
this user once before. They will always be asked for a secondary ID if
they replace their cert photo in the future.
</div>
{{end}}
<div class="field"> <div class="field">
<textarea class="textarea" name="comment" <textarea class="textarea" name="comment"
cols="60" rows="2" cols="60" rows="2"
@ -143,15 +172,21 @@
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select class="common-reasons"> <select class="common-reasons">
<option value="">(Common Rejection Reasons)</option> <optgroup label="Common Rejection Reasons">
<option value="Your certification pic should depict you holding onto a sheet of paper with your username, site name, and current date written on it."> <option value="Your certification pic should depict you holding onto a sheet of paper with your username, site name, and current date written on it.">
Didn't follow directions Didn't follow directions
</option> </option>
<option value="The sheet of paper must also include the website name: nonshy">Website name not visible</option> <option value="The sheet of paper must also include the website name: nonshy">Website name not visible</option>
<option value="Please take a clearer picture that shows your arm and hand holding onto the sheet of paper">Unclear picture (hand not visible enough)</option> <option value="Please take a clearer picture that shows your arm and hand holding onto the sheet of paper">Unclear picture (hand not visible enough)</option>
<option value="This photo has been digitally altered, please take a new certification picture and upload it as it comes off your camera">Photoshopped or digitally altered</option> <option value="This photo has been digitally altered, please take a new certification picture and upload it as it comes off your camera">Photoshopped or digitally altered</option>
<option value="You had a previous account on nonshy which was suspended and you are not welcome with a new account.">User was previously banned from nonshy</option> <option value="You had a previous account on nonshy which was suspended and you are not welcome with a new account.">User was previously banned from nonshy</option>
<option value="This is not an acceptable certification photo.">Not acceptable</option> <option value="This is not an acceptable certification photo.">Not acceptable</option>
</optgroup>
<optgroup label="Secondary Verification">
<option value="(secondary)">
User appears underage, ask for secondary ID
</option>
</optgroup>
<optgroup label="Other Actions"> <optgroup label="Other Actions">
<option value="(ignore)">(Silently reject this photo without sending an e-mail)</option> <option value="(ignore)">(Silently reject this photo without sending an e-mail)</option>
</optgroup> </optgroup>

View File

@ -0,0 +1,34 @@
{{define "content"}}
<html>
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
<h1>A secondary form of photo ID verification is requested</h1>
<p>Dear {{.Data.Username}},</p>
<p>
An admin has reviewed your certification photo and is concerned that you may be
too young to sign up for nonshy.
</p>
<p>
To get your certification photo approved, you will need to upload a copy of an
official photo ID that shows your <strong>face</strong> and your <strong>date of birth</strong>.
All other information on your ID should be blacked out before sharing it.
</p>
<p>
Please see the Certification Photo upload page for more information:
</p>
<p>
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>
</p>
<p>
This is an automated e-mail; do not reply to this message.
</p>
</body>
</html>
{{end}}

View File

@ -31,6 +31,8 @@
<span class="tag is-info">Pending Approval</span> <span class="tag is-info">Pending Approval</span>
{{else if eq .CertificationPhoto.Status "approved"}} {{else if eq .CertificationPhoto.Status "approved"}}
<span class="tag is-success">Approved</span> <span class="tag is-success">Approved</span>
{{else if eq .CertificationPhoto.Status "secondary"}}
<span class="tag is-info">Secondary ID Requested</span>
{{else if eq .CertificationPhoto.Status "rejected"}} {{else if eq .CertificationPhoto.Status "rejected"}}
<span class="tag is-danger">Rejected</span> <span class="tag is-danger">Rejected</span>
{{else}} {{else}}
@ -38,6 +40,111 @@
{{end}} {{end}}
</div> </div>
<!-- Secondary form of ID requested? -->
{{if eq .CertificationPhoto.Status "secondary"}}
<div class="notification is-info is-light content">
<p>
A {{PrettyTitle}} admin has reviewed your certification photo and has requested
that you show a secondary form of photo ID to verify your age.
</p>
<p>
Please upload a scan or photo of an official, government issued ID which shows
a photo of your face and your date of birth. <strong>Please black out any other
personal information from your ID.</strong> We do not want your name, address,
ID number, or any sensitive information - <em>just</em> your <strong>photo</strong>
and your <strong>date of birth</strong>.
</p>
<p>
Please refer to this <strong>example photo ID:</strong>
</p>
<p>
<img src="/static/img/photoid-example.jpg">
</p>
<p>
Acceptable forms of ID may include the following:
</p>
<ul>
<li>A government issued passport</li>
<li>A government issued driver's license or photo ID card</li>
</ul>
<p>
Please prepare this scan of your photo ID and upload it below.
</p>
<p>
For your privacy, <strong>we will delete this image</strong> from
the {{PrettyTitle}} database after it has been reviewed.
</p>
<form method="POST" action="/photo/certification" enctype="multipart/form-data">
{{InputCSRF}}
<input type="hidden" name="secondary" value="true">
<div class="field">
<label for="file" class="label">Browse and select your photo ID scan:</label>
<div class="file has-name is-fullwidth">
<label class="file-label">
<input class="file-input" type="file"
name="file"
id="file"
accept=".jpg,.jpeg,.jpe,.png"
required>
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name" id="fileName">
Select a file
</span>
</label>
</div>
</div>
<div class="block has-text-centered">
<button type="submit" class="button is-primary">Upload Photo ID</button>
</div>
</form>
</div>
{{else if ne .CertificationPhoto.SecondaryFilename ""}}
<div class="notification is-success is-light content">
<p>
Your <strong>secondary form of photo ID</strong> is pending approval. It will
be deleted from the website's database after it has been reviewed.
</p>
<p>
If you wish to delete it immediately, please use the button below to
"Delete My Certification Photo." It will remove your original certification
photo as well as the secondary photo ID that you had uploaded before.
</p>
</div>
{{else if .CertificationPhoto.SecondaryVerified}}
<div class="notification is-success is-light content">
<p>
Your <i class="fa fa-id-card"></i> <strong>secondary form of photo ID</strong> has
been reviewed and approved by an administrator. We have removed the image itself
from the server.
</p>
<p>
Note: if you delete your Certification Photo at this point, or upload a new one,
you will be requested to re-upload your photo ID again as well.
</p>
</div>
{{end}}
<!-- Admin rejection comment -->
{{if .CertificationPhoto.AdminComment}} {{if .CertificationPhoto.AdminComment}}
<div class="notification is-warning content"> <div class="notification is-warning content">
<p> <p>
@ -77,7 +184,7 @@
onclick="return window.confirm('Removing this photo will mark your account as Not Certified.\n\nYou will then need to upload a new certification photo for approval to certify your account again.\n\nAre you sure you want to do this?')" onclick="return window.confirm('Removing this photo will mark your account as Not Certified.\n\nYou will then need to upload a new certification photo for approval to certify your account again.\n\nAre you sure you want to do this?')"
{{end}}> {{end}}>
<span class="icon"><i class="fa fa-trash"></i></span> <span class="icon"><i class="fa fa-trash"></i></span>
<span>Delete This Photo</span> <span>Delete My Certification Photo</span>
</button> </button>
</div> </div>
</form> </form>