Anonymous contact authentication for events

This commit is contained in:
Noah 2018-05-11 21:29:18 -07:00
parent a166c72cf3
commit a3ba16c9b2
9 changed files with 196 additions and 13 deletions

View File

@ -4,6 +4,7 @@ package blog
import (
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/gorilla/mux"
@ -56,6 +57,10 @@ type Blog struct {
// New initializes the Blog application.
func New(documentRoot, userRoot string) *Blog {
// Initialize the SQLite database.
if _, err := os.Stat(filepath.Join(userRoot, ".private")); os.IsNotExist(err) {
os.MkdirAll(filepath.Join(userRoot, ".private"), 0755)
}
db, err := gorm.Open("sqlite3", filepath.Join(userRoot, ".private", "database.sqlite"))
if err != nil {
panic(err)

View File

@ -7,7 +7,9 @@ package main
import (
"flag"
"fmt"
"math/rand"
"os"
"time"
_ "github.com/jinzhu/gorm/dialects/sqlite" // SQLite DB
"github.com/kirsle/blog"
@ -32,6 +34,7 @@ func init() {
flag.BoolVar(&fDebug, "d", false, "Debug mode (alias)")
flag.StringVar(&fAddress, "address", ":8000", "Bind address")
flag.StringVar(&fAddress, "a", ":8000", "Bind address (alias)")
rand.Seed(time.Now().UnixNano())
}
func main() {

View File

@ -0,0 +1,88 @@
package events
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/kirsle/blog/internal/log"
"github.com/kirsle/blog/internal/responses"
"github.com/kirsle/blog/internal/sessions"
"github.com/kirsle/blog/models/contacts"
"github.com/kirsle/blog/models/events"
)
// AuthedContact returns the current authenticated Contact, if any, on the session.
func AuthedContact(r *http.Request) (contacts.Contact, error) {
session := sessions.Get(r)
if contactID, ok := session.Values["contact-id"].(int); ok && contactID != 0 {
contact, err := contacts.Get(contactID)
return contact, err
}
return contacts.Contact{}, errors.New("not authenticated")
}
// contactAuthHandler listens at "/c/<contact secret>?e=<event id>"
//
// It is used in RSVP invite emails so when the user clicks the link, it auto
// authenticates their session as the contact ID using the contact secret
// (a randomly generated string in the DB). The ?e= param indicates an event
// ID to redirect to.
func contactAuthHandler(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
secret, ok := params["secret"]
if !ok {
responses.BadRequest(w, r, "Bad Request")
}
c, err := contacts.GetBySecret(secret)
if err != nil {
log.Error("contactAuthHandler (/c/<secret>): secret not found, don't know this user")
responses.Redirect(w, "/")
return
}
log.Info("contactAuthHandler: Contact %d (%s) is now authenticated", c.ID, c.Name())
// Authenticate the contact in the session.
session := sessions.Get(r)
session.Values["contact-id"] = c.ID
err = session.Save(r, w)
if err != nil {
log.Error("contactAuthHandler: save session error: %s", err)
}
// Did they give us an event ID?
eventIDStr := r.FormValue("e")
if eventIDStr != "" {
if eventID, err := strconv.Atoi(eventIDStr); err == nil {
event, err := events.Load(eventID)
if err != nil {
responses.FlashAndRedirect(w, r, "/", "Event %d not found", eventID)
return
}
// Redirect to the event.
responses.Redirect(w, "/e/"+event.Fragment)
return
}
}
// Redirect home I guess?
log.Error("contactAuthHandler: don't know where to send them (no ?e= param for event ID)")
responses.Redirect(w, "/")
}
func contactLogoutHandler(w http.ResponseWriter, r *http.Request) {
session := sessions.Get(r)
delete(session.Values, "contact-id")
session.Save(r, w)
if next := r.FormValue("next"); next != "" && strings.HasPrefix(next, "/") {
responses.Redirect(w, next)
} else {
responses.Redirect(w, "/")
}
}

View File

@ -29,6 +29,8 @@ func Register(r *mux.Router, loginError http.HandlerFunc) {
// Public routes
r.HandleFunc("/e/{fragment}", viewHandler)
r.HandleFunc("/c/logout", contactLogoutHandler)
r.HandleFunc("/c/{secret}", contactAuthHandler)
}
// Admin index to view all events.
@ -60,10 +62,50 @@ func viewHandler(w http.ResponseWriter, r *http.Request) {
return
}
sort.Sort(events.ByName(event.RSVP))
// Template variables.
v := map[string]interface{}{
"event": event,
"authedRSVP": events.RSVP{},
}
// Sort the guest list.
sort.Sort(events.ByName(event.RSVP))
// Is the browser session authenticated as a contact?
authedContact, err := AuthedContact(r)
if err == nil {
v["authedContact"] = authedContact
// Do they have an RSVP?
for _, rsvp := range event.RSVP {
if rsvp.ContactID == authedContact.ID {
v["authedRSVP"] = rsvp
break
}
}
}
// If we're posting, are we RSVPing?
if r.Method == http.MethodPost {
action := r.PostFormValue("action")
switch action {
case "answer-rsvp":
answer := r.PostFormValue("submit")
for _, rsvp := range event.RSVP {
if rsvp.ContactID == authedContact.ID {
log.Info("Mark RSVP status %s for contact %s", answer, authedContact.Name())
rsvp.Status = answer
rsvp.Save()
responses.FlashAndReload(w, r, "You have confirmed '%s' for your RSVP.", answer)
return
}
}
default:
responses.FlashAndReload(w, r, "Invalid form action.")
}
responses.FlashAndReload(w, r, "Unknown error.")
return
}
render.Template(w, r, "events/view", v)
}

View File

@ -16,15 +16,26 @@ func notifyUser(ev *events.Event, rsvp events.RSVP) {
)
s, _ := settings.Load()
// Can we get an "auto-login" link?
var claimURL string
if rsvp.Contact.Secret != "" {
claimURL = fmt.Sprintf("%s/c/%s?e=%d",
strings.Trim(s.Site.URL, "/"),
rsvp.Contact.Secret,
ev.ID,
)
}
// Do they have... an e-mail address?
if email != "" {
mail.SendEmail(mail.Email{
go mail.SendEmail(mail.Email{
To: email,
Subject: fmt.Sprintf("Invitation to: %s", ev.Title),
Data: map[string]interface{}{
"RSVP": rsvp,
"Event": ev,
"URL": strings.Trim(s.Site.URL, "/") + "/e/" + ev.Fragment,
"ClaimURL": claimURL,
},
Template: ".email/event-invite.gohtml",
})
@ -32,7 +43,7 @@ func notifyUser(ev *events.Event, rsvp events.RSVP) {
// An SMS number?
if sms != "" {
// TODO: Twilio
}
rsvp.Notified = true

View File

@ -2,6 +2,7 @@ package contacts
import (
"errors"
"fmt"
"math/rand"
"net/http"
"strings"
@ -65,6 +66,7 @@ func (c *Contact) presave() {
secret[i] = letters[rand.Intn(len(letters))]
}
c.Secret = string(secret)
fmt.Printf("contact.presave: secret=%s", string(secret))
}
}
@ -111,6 +113,13 @@ func GetSMS(number string) (Contact, error) {
return contact, err
}
// GetBySecret queries a contact by their secret.
func GetBySecret(secret string) (Contact, error) {
contact := Contact{}
err := DB.Where("secret = ?", secret).First(&contact).Error
return contact, err
}
// Name returns a friendly name for the contact.
func (c Contact) Name() string {
var parts []string

View File

@ -30,7 +30,7 @@
To view the details and RSVP, visit the link below:
<br><br>
<a href="{{ .Data.URL }}" target="_blank">{{ .Data.URL }}</a>
<a href="{{ or .Data.ClaimURL .Data.URL }}" target="_blank">{{ or .Data.ClaimURL .Data.URL }}</a>
</font>
</td>
</tr>

View File

@ -40,7 +40,7 @@
{{ else }}
<strong>{{ $rsvp.Name }}</strong>
{{ end }}
<button type="submit" class="btn btn-sm btn-danger">delete</button>
<button type="submit" class="btn btn-sm btn-danger">uninvite</button>
</form>
<ul class="list-inline">
{{ if .Contact }}

View File

@ -1,7 +1,32 @@
{{ define "title" }}{{ .Data.event.Title }}{{ end }}
{{ define "content" }}
{{ $authedContact := .Data.authedContact }}
{{ $authedRSVP := .Data.authedRSVP }}
{{ with .Data.event }}
{{ if and $authedContact $authedRSVP.ID }}
<div class="row mb-4">
<div class="col-8">
<p>
<strong>{{ $authedContact.Name }}</strong>, you have been invited to...
</p>
</div>
<div class="col-4 text-right">
<form name="rsvpAnswerForm" action="/e/{{ .Fragment }}" method="POST">
<input type="hidden" name="_csrf" value="{{ $.CSRF }}">
<input type="hidden" name="action" value="answer-rsvp">
<div class="btn-group">
<button type="submit" name="submit" value="going" class="btn{{ if eq $authedRSVP.Status "going" }} btn-success{{ end }}">Going</button>
<button type="submit" name="submit" value="maybe" class="btn{{ if eq $authedRSVP.Status "maybe" }} btn-warning{{ end }}">Maybe</button>
<button type="submit" name="submit" value="not going" class="btn{{ if eq $authedRSVP.Status "not going" }} btn-danger{{ end }}">Not Going</button>
</div>
<p class="small">
[<a href="/c/logout?next={{ $.Request.URL.Path }}">not {{ $authedContact.Name }}?</a>]
</p>
</div>
</div>
{{ end }}
<h1>{{ .Title }}</h1>
<div class="row mb-4">
@ -36,9 +61,9 @@
{{ range .RSVP }}
<li class="list-group-item
{{ if eq .Status "invited" }}bg-light
{{ else if eq .Status "going"}}bg-success text-light
{{ else if eq .Status "not going"}}bg-danger text-light
{{ else if eq .Status "maybe"}}bg-warning text-light{{ end }}">
{{ else if eq .Status "going"}}border-success
{{ else if eq .Status "not going"}}border-danger
{{ else if eq .Status "maybe"}}border-warning{{ end }}">
{{ if .Contact }}
<strong>{{ .Contact.Name }}</strong>
{{ else }}
@ -51,7 +76,7 @@
{{ if not .Notified }}
<span class="badge badge-warning">not notified</span>
{{ end }}
<ul class="list-inline">
<ul class="list-inline small">
{{ if .Contact }}
{{ if .Contact.Email }}
<li class="list-inline-item text-muted">