Two Factor Authentication
This commit is contained in:
parent
41beba54f2
commit
c3a3b7e35c
2
go.mod
2
go.mod
|
@ -15,6 +15,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
@ -37,6 +38,7 @@ require (
|
||||||
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
|
||||||
github.com/oschwald/geoip2-golang v1.9.0 // indirect
|
github.com/oschwald/geoip2-golang v1.9.0 // indirect
|
||||||
github.com/oschwald/maxminddb-golang v1.11.0 // indirect
|
github.com/oschwald/maxminddb-golang v1.11.0 // indirect
|
||||||
|
github.com/pquerna/otp v1.4.0 // indirect
|
||||||
github.com/russross/blackfriday v1.5.2 // indirect
|
github.com/russross/blackfriday v1.5.2 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -4,6 +4,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
|
@ -115,6 +117,8 @@ github.com/oschwald/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZI
|
||||||
github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg=
|
github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||||
|
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
|
|
|
@ -35,6 +35,9 @@ const (
|
||||||
SessionRedisKeyFormat = "session/%s"
|
SessionRedisKeyFormat = "session/%s"
|
||||||
MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs)
|
MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs)
|
||||||
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
|
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
|
||||||
|
|
||||||
|
TwoFactorBackupCodeCount = 12
|
||||||
|
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||||
)
|
)
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
|
|
|
@ -7,13 +7,14 @@ import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version of the config format - when new fields are added, it will attempt
|
// Version of the config format - when new fields are added, it will attempt
|
||||||
// to write the settings.toml to disk so new defaults populate.
|
// to write the settings.toml to disk so new defaults populate.
|
||||||
var currentVersion = 1
|
var currentVersion = 2
|
||||||
|
|
||||||
// Current loaded settings.json
|
// Current loaded settings.json
|
||||||
var Current = DefaultVariable()
|
var Current = DefaultVariable()
|
||||||
|
@ -29,6 +30,7 @@ type Variable struct {
|
||||||
Database Database
|
Database Database
|
||||||
BareRTC BareRTC
|
BareRTC BareRTC
|
||||||
Maintenance Maintenance
|
Maintenance Maintenance
|
||||||
|
Encryption Encryption
|
||||||
UseXForwardedFor bool
|
UseXForwardedFor bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,6 +58,8 @@ func DefaultVariable() Variable {
|
||||||
|
|
||||||
// LoadSettings loads the settings.json file or, if not existing, creates it with the default settings.
|
// LoadSettings loads the settings.json file or, if not existing, creates it with the default settings.
|
||||||
func LoadSettings() {
|
func LoadSettings() {
|
||||||
|
var writeSettings bool
|
||||||
|
|
||||||
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
|
||||||
log.Info("Loading settings from %s", SettingsPath)
|
log.Info("Loading settings from %s", SettingsPath)
|
||||||
content, err := ioutil.ReadFile(SettingsPath)
|
content, err := ioutil.ReadFile(SettingsPath)
|
||||||
|
@ -81,8 +85,20 @@ func LoadSettings() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the AES encryption key.
|
||||||
|
if len(Current.Encryption.AESKey) == 0 {
|
||||||
|
log.Warn("NOTICE: rolling a random 32-byte (256-bit) AES encryption key for the settings file")
|
||||||
|
aesKey, err := keygen.NewAESKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't generate AES key: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
Current.Encryption.AESKey = aesKey
|
||||||
|
writeSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
// Have we added new config fields? Save the settings.json.
|
// Have we added new config fields? Save the settings.json.
|
||||||
if Current.Version != currentVersion {
|
if Current.Version != currentVersion || writeSettings {
|
||||||
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
||||||
Current.Version = currentVersion
|
Current.Version = currentVersion
|
||||||
if err := WriteSettings(); err != nil {
|
if err := WriteSettings(); err != nil {
|
||||||
|
@ -144,3 +160,8 @@ type Maintenance struct {
|
||||||
PauseChat bool
|
PauseChat bool
|
||||||
PauseInteraction bool
|
PauseInteraction bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encryption settings.
|
||||||
|
type Encryption struct {
|
||||||
|
AESKey []byte
|
||||||
|
}
|
||||||
|
|
|
@ -96,6 +96,9 @@ func Dashboard() http.HandlerFunc {
|
||||||
|
|
||||||
// Who's Nearby stats.
|
// Who's Nearby stats.
|
||||||
"MyLocation": myLocation,
|
"MyLocation": myLocation,
|
||||||
|
|
||||||
|
// Check 2FA enabled status for new feature announcement.
|
||||||
|
"TwoFactorEnabled": models.Get2FA(currentUser.ID).Enabled,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
// Login controller.
|
// Login controller.
|
||||||
func Login() http.HandlerFunc {
|
func Login() http.HandlerFunc {
|
||||||
tmpl := templates.Must("account/login.html")
|
tmpl := templates.Must("account/login.html")
|
||||||
|
tmpl2fa := templates.Must("account/two_factor_login.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var next = r.FormValue("next")
|
var next = r.FormValue("next")
|
||||||
|
|
||||||
|
@ -75,6 +76,46 @@ func Login() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear their login rate limiter.
|
||||||
|
limiter.Clear()
|
||||||
|
|
||||||
|
// Does the user have Two-Factor Auth enabled?
|
||||||
|
var (
|
||||||
|
tf = models.Get2FA(user.ID)
|
||||||
|
twoFactorOK bool // has successfully entered the code
|
||||||
|
)
|
||||||
|
if tf.Enabled {
|
||||||
|
// Are they submitting the 2FA code?
|
||||||
|
var (
|
||||||
|
intent = r.PostFormValue("intent")
|
||||||
|
code = r.PostFormValue("code")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate the submitted code.
|
||||||
|
if intent == "two-factor" {
|
||||||
|
// Verify the TOTP code.
|
||||||
|
if err := tf.Validate(code); err != nil {
|
||||||
|
session.FlashError(w, r, "Invalid authentication code; please try again.")
|
||||||
|
} else {
|
||||||
|
// We're in!
|
||||||
|
twoFactorOK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the 2FA login form.
|
||||||
|
if !twoFactorOK {
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Next": next,
|
||||||
|
"Username": username,
|
||||||
|
"Password": password,
|
||||||
|
}
|
||||||
|
if err := tmpl2fa.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OK. Log in the user's session.
|
// OK. Log in the user's session.
|
||||||
session.LoginUser(w, r, user)
|
session.LoginUser(w, r, user)
|
||||||
|
|
||||||
|
|
|
@ -123,10 +123,22 @@ func Settings() http.HandlerFunc {
|
||||||
hashtag = "#prefs"
|
hashtag = "#prefs"
|
||||||
var (
|
var (
|
||||||
explicit = r.PostFormValue("explicit") == "true"
|
explicit = r.PostFormValue("explicit") == "true"
|
||||||
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
user.Explicit = explicit
|
user.Explicit = explicit
|
||||||
|
|
||||||
|
if err := user.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "Website preferences updated!")
|
||||||
|
case "privacy":
|
||||||
|
hashtag = "#privacy"
|
||||||
|
var (
|
||||||
|
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
||||||
|
dmPrivacy = r.PostFormValue("dm_privacy")
|
||||||
|
)
|
||||||
|
|
||||||
user.Visibility = models.UserVisibilityPublic
|
user.Visibility = models.UserVisibilityPublic
|
||||||
|
|
||||||
for _, cmp := range models.UserVisibilityOptions {
|
for _, cmp := range models.UserVisibilityOptions {
|
||||||
|
@ -136,13 +148,13 @@ func Settings() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set profile field prefs.
|
// Set profile field prefs.
|
||||||
user.SetProfileField("dm_privacy", r.PostFormValue("dm_privacy"))
|
user.SetProfileField("dm_privacy", dmPrivacy)
|
||||||
|
|
||||||
if err := user.Save(); err != nil {
|
if err := user.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
session.Flash(w, r, "Website preferences updated!")
|
session.Flash(w, r, "Privacy settings updated!")
|
||||||
case "location":
|
case "location":
|
||||||
hashtag = "#location"
|
hashtag = "#location"
|
||||||
var (
|
var (
|
||||||
|
@ -269,6 +281,9 @@ func Settings() http.HandlerFunc {
|
||||||
vars["GeoIPInsights"] = insights
|
vars["GeoIPInsights"] = insights
|
||||||
vars["UserLocation"] = models.GetUserLocation(user.ID)
|
vars["UserLocation"] = models.GetUserLocation(user.ID)
|
||||||
|
|
||||||
|
// Show enabled status for 2FA.
|
||||||
|
vars["TwoFactorEnabled"] = models.Get2FA(user.ID).Enabled
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
148
pkg/controller/account/two_factor.go
Normal file
148
pkg/controller/account/two_factor.go
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2FA Setup page (/account/two-factor/setup)
|
||||||
|
func Setup2FA() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("account/two_factor_setup.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Load the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get their current 2FA settings.
|
||||||
|
tf := models.Get2FA(currentUser.ID)
|
||||||
|
|
||||||
|
// If they aren't already set up, prepare a new TOTP secret for first-time setup.
|
||||||
|
var key *otp.Key
|
||||||
|
if tf.IsNew() {
|
||||||
|
// Generate new TOTP parameters.
|
||||||
|
if newKey, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: config.Title,
|
||||||
|
AccountName: currentUser.Username,
|
||||||
|
}); err != nil {
|
||||||
|
session.FlashError(w, r, "Error generating TOTP: %s", err)
|
||||||
|
templates.Redirect(w, "/me")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
key = newKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the secret.
|
||||||
|
tf.SetSecret(key.URL())
|
||||||
|
|
||||||
|
// Save it.
|
||||||
|
if err := tf.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error saving TOTP settings to the database: %s", err)
|
||||||
|
templates.Redirect(w, "/me")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reconstruct the stored TOTP key.
|
||||||
|
secret, err := tf.GetSecret()
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Error retrieving 2FA secret: %s", err)
|
||||||
|
templates.Redirect(w, "/me")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the OTP key object.
|
||||||
|
if k, err := otp.NewKeyFromURL(secret); err != nil {
|
||||||
|
session.FlashError(w, r, "Error retrieving TOTP key: %s", err)
|
||||||
|
templates.Redirect(w, "/me")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
key = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST form actions.
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var intent = r.PostFormValue("intent")
|
||||||
|
switch intent {
|
||||||
|
case "setup-verify":
|
||||||
|
// Setup: verify correct enrollment.
|
||||||
|
var (
|
||||||
|
code = r.PostFormValue("code")
|
||||||
|
valid = totp.Validate(code, key.Secret())
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid?
|
||||||
|
if !valid {
|
||||||
|
session.FlashError(w, r, "The passcode you submitted didn't seem correct. Try a new six-digit code.")
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK!
|
||||||
|
tf.Enabled = true
|
||||||
|
if err := tf.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error saving your TOTP settings to the database: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "The authentication code was validated successfully! Two-Factor Authentication is now active for your account.")
|
||||||
|
}
|
||||||
|
case "regenerate-backup-codes":
|
||||||
|
// Re-generate backup codes.
|
||||||
|
if err := tf.GenerateBackupCodes(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error generating backup codes: %s", err)
|
||||||
|
} else {
|
||||||
|
// Save the changes.
|
||||||
|
if err := tf.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error saving your TOTP settings to the database: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Your backup codes have been regenerated!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "disable":
|
||||||
|
// Disable 2FA. User password is required.
|
||||||
|
var password = r.PostFormValue("password")
|
||||||
|
if err := currentUser.CheckPassword(password); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't disable 2FA: the password you entered is incorrect.")
|
||||||
|
} else {
|
||||||
|
// Delete the 2FA configuration.
|
||||||
|
if err := tf.Delete(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't delete 2FA setting from the database: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Your 2FA settings have been cleared and disabled.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
session.FlashError(w, r, "Unknown intent: %s", intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the QR code.
|
||||||
|
qrCode, err := tf.QRCodeAsDataURL(key)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("TwoFactor: Couldn't create QR code: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"TwoFactor": tf,
|
||||||
|
"Key": key,
|
||||||
|
"QRCode": qrCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
108
pkg/encryption/encryption.go
Normal file
108
pkg/encryption/encryption.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
// Package encryption provides functions to encode/decode AES encrypted secrets.
|
||||||
|
//
|
||||||
|
// Encryption is used to store sensitive information in the database, such as 2FA TOTP secrets
|
||||||
|
// for users who have 2FA authentication enabled.
|
||||||
|
//
|
||||||
|
// For new key generation, see pkg/config/variable.go#NewAESKey.
|
||||||
|
package encryption
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encrypt a byte stream using the site's AES passphrase.
|
||||||
|
func Encrypt(input []byte) ([]byte, error) {
|
||||||
|
if len(config.Current.Encryption.AESKey) == 0 {
|
||||||
|
return nil, errors.New("AES key not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new AES cipher.
|
||||||
|
c, err := aes.NewCipher(config.Current.Encryption.AESKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// gcm or Galois/Counter Mode
|
||||||
|
gcm, err := cipher.NewGCM(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new byte array the size of the GCM nonce
|
||||||
|
// which must be passed to Seal.
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("populating the nonce: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the text using the Seal function.
|
||||||
|
// Seal encrypts and authenticates plaintext, authenticates the
|
||||||
|
// additional data and appends the result to dst, returning the
|
||||||
|
// updated slice. The nonce must be NonceSize() bytes long and
|
||||||
|
// unique for all time, for a given key.
|
||||||
|
result := gcm.Seal(nonce, nonce, input, nil)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptString encrypts a string value and returns the cipher text.
|
||||||
|
func EncryptString(input string) ([]byte, error) {
|
||||||
|
return Encrypt([]byte(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt a byte stream using the site's AES passphrase.
|
||||||
|
func Decrypt(data []byte) ([]byte, error) {
|
||||||
|
if len(config.Current.Encryption.AESKey) == 0 {
|
||||||
|
return nil, errors.New("AES key not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := aes.NewCipher(config.Current.Encryption.AESKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return nil, errors.New("ciphertext data less than nonceSize")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptString decrypts a string value from ciphertext.
|
||||||
|
func DecryptString(data []byte) (string, error) {
|
||||||
|
decoded, err := Decrypt(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash a byte array as SHA256 and returns the hex string.
|
||||||
|
func Hash(input []byte) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write(input)
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyHash hashes a byte array and checks the result.
|
||||||
|
func VerifyHash(input []byte, expect string) bool {
|
||||||
|
return Hash(input) == expect
|
||||||
|
}
|
103
pkg/encryption/encryption_test.go
Normal file
103
pkg/encryption/encryption_test.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package encryption_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncryption(t *testing.T) {
|
||||||
|
var tests = []struct {
|
||||||
|
Input []byte
|
||||||
|
Output []byte
|
||||||
|
Key []byte
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Input: []byte("Hello, world!"),
|
||||||
|
Output: []byte("Hello, world!"),
|
||||||
|
Key: []byte("passphrasewhichneedstobe32bytes!"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tc := range tests {
|
||||||
|
if len(tc.Key) != 32 {
|
||||||
|
t.Errorf("Test #%d: key is not 32 bytes", i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Current.Encryption.AESKey = tc.Key
|
||||||
|
cipher, err := encryption.Encrypt(tc.Input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Test #%d: unexpected error from Encrypt: %s", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := encryption.Decrypt(cipher)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Test #%d: unexpected error from Decrypt: %s", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !EqualSlice(result, tc.Output) {
|
||||||
|
t.Errorf("Test #%d: didn't get expected decrypted output", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonces(t *testing.T) {
|
||||||
|
// Verify that the same text encrypted twice has a different output (nonce),
|
||||||
|
// but both decrypt all the same.
|
||||||
|
var (
|
||||||
|
key = []byte("passphrasewhichneedstobe32bytes!")
|
||||||
|
plaintext = []byte("Hello, world!!")
|
||||||
|
)
|
||||||
|
|
||||||
|
config.Current.Encryption.AESKey = key
|
||||||
|
|
||||||
|
// Encrypt them both.
|
||||||
|
cipherA, err := encryption.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected failure when encrypting cipherA: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cipherB, err := encryption.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected failure when encrypting cipherB: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// They should not be equal.
|
||||||
|
if EqualSlice(cipherA, cipherB) {
|
||||||
|
t.Errorf("The two ciphertexts were unexpectedly equal!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt them both.
|
||||||
|
resultA, err := encryption.Decrypt(cipherA)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected failure when decrypting cipherA: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resultB, err := encryption.Decrypt(cipherB)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected failure when decrypting cipherB: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect them to be equal.
|
||||||
|
if !EqualSlice(resultA, resultB) {
|
||||||
|
t.Errorf("The two decrypted slices were expected to be equal, but were not!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EqualSlice(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, value := range a {
|
||||||
|
if b[i] != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
11
pkg/encryption/keygen/keygen.go
Normal file
11
pkg/encryption/keygen/keygen.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// Package keygen provides the AES key initializer function.
|
||||||
|
package keygen
|
||||||
|
|
||||||
|
import "crypto/rand"
|
||||||
|
|
||||||
|
// NewAESKey returns a 32-byte (AES 256 bit) encryption key.
|
||||||
|
func NewAESKey() ([]byte, error) {
|
||||||
|
var result = make([]byte, 32)
|
||||||
|
_, err := rand.Read(result)
|
||||||
|
return result, err
|
||||||
|
}
|
|
@ -30,4 +30,5 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&AdminScope{})
|
DB.AutoMigrate(&AdminScope{})
|
||||||
DB.AutoMigrate(&UserLocation{})
|
DB.AutoMigrate(&UserLocation{})
|
||||||
DB.AutoMigrate(&UserNote{})
|
DB.AutoMigrate(&UserNote{})
|
||||||
|
DB.AutoMigrate(&TwoFactor{})
|
||||||
}
|
}
|
||||||
|
|
246
pkg/models/two_factor.go
Normal file
246
pkg/models/two_factor.go
Normal file
|
@ -0,0 +1,246 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TwoFactor table to hold 2FA TOTP tokens for more secure login.
|
||||||
|
type TwoFactor struct {
|
||||||
|
UserID uint64 `gorm:"primaryKey"` // owner ID
|
||||||
|
Enabled bool
|
||||||
|
EncryptedSecret []byte // encrypted OTP secret (URL format)
|
||||||
|
HashedSecret string // verification hash for the EncryptedSecret being decoded correctly
|
||||||
|
BackupCodes []byte // encrypted backup codes
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
// Private vars
|
||||||
|
isNew bool // needs creation, didn't exist in DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNew returns if the 2FA record was freshly generated (not in DB yet).
|
||||||
|
func (tf *TwoFactor) IsNew() bool {
|
||||||
|
return tf.isNew
|
||||||
|
}
|
||||||
|
|
||||||
|
// New2FA initializes a TwoFactor config for a user, with randomly generated secrets.
|
||||||
|
func New2FA(userID uint64) *TwoFactor {
|
||||||
|
var tf = &TwoFactor{
|
||||||
|
isNew: true,
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate backup codes.
|
||||||
|
if err := tf.GenerateBackupCodes(); err != nil {
|
||||||
|
log.Error("New2FA(%d): GenerateBackupCodes: %s", userID, err)
|
||||||
|
}
|
||||||
|
return tf
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2FA looks up the TwoFactor config for a user, or returns an empty struct ready to initialize.
|
||||||
|
func Get2FA(userID uint64) *TwoFactor {
|
||||||
|
var (
|
||||||
|
tf = &TwoFactor{}
|
||||||
|
result = DB.First(&tf, userID)
|
||||||
|
)
|
||||||
|
if result.Error != nil {
|
||||||
|
return New2FA(userID)
|
||||||
|
}
|
||||||
|
return tf
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSecret sets (and encrypts) the EncryptedSecret.
|
||||||
|
func (tf *TwoFactor) SetSecret(url string) error {
|
||||||
|
// Get the hash of the original secret for verification.
|
||||||
|
hash := encryption.Hash([]byte(url))
|
||||||
|
|
||||||
|
// Encrypt it.
|
||||||
|
ciphertext, err := encryption.EncryptString(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store it.
|
||||||
|
tf.EncryptedSecret = ciphertext
|
||||||
|
tf.HashedSecret = hash
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecret decrypts and verifies the TOTP secret (URL).
|
||||||
|
func (tf *TwoFactor) GetSecret() (string, error) {
|
||||||
|
// Decrypt it.
|
||||||
|
plaintext, err := encryption.DecryptString(tf.EncryptedSecret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it.
|
||||||
|
if !encryption.VerifyHash([]byte(plaintext), tf.HashedSecret) {
|
||||||
|
return "", errors.New("hash of secret did not match: the site AES key may be wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a given 2FA code or Backup Code.
|
||||||
|
func (tf *TwoFactor) Validate(code string) error {
|
||||||
|
// Reconstruct the stored TOTP key.
|
||||||
|
secret, err := tf.GetSecret()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the OTP key object.
|
||||||
|
key, err := otp.NewKeyFromURL(secret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for TOTP secret.
|
||||||
|
if totp.Validate(code, key.Secret()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for (and burn) a Backup Code.
|
||||||
|
if tf.ValidateBackupCode(code) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("not a valid code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBackupCodes will generate and reset the backup codes (encrypted).
|
||||||
|
func (tf *TwoFactor) GenerateBackupCodes() error {
|
||||||
|
var (
|
||||||
|
codes = []string{}
|
||||||
|
distinct = map[string]interface{}{}
|
||||||
|
alphabet = []byte("abcdefghijklmnopqrstuvwxyz0123456789")
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := 0; i < config.TwoFactorBackupCodeCount; i++ {
|
||||||
|
for {
|
||||||
|
var code []byte
|
||||||
|
for j := 0; j < config.TwoFactorBackupCodeLength; j++ {
|
||||||
|
code = append(code, alphabet[rand.Intn(len(alphabet))])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for distinctness.
|
||||||
|
var codeStr = string(code)
|
||||||
|
if _, ok := distinct[codeStr]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
distinct[codeStr] = nil
|
||||||
|
|
||||||
|
codes = append(codes, codeStr)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the codes.
|
||||||
|
return tf.SetBackupCodes(codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBackupCodes encrypts and stores the codes to DB.
|
||||||
|
func (tf *TwoFactor) SetBackupCodes(codes []string) error {
|
||||||
|
ciphertext, err := encryption.EncryptString(strings.Join(codes, ","))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tf.BackupCodes = ciphertext
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackupCodes returns the list of still-valid backup codes.
|
||||||
|
func (tf *TwoFactor) GetBackupCodes() ([]string, error) {
|
||||||
|
// Decrypt the backup codes.
|
||||||
|
plaintext, err := encryption.DecryptString(tf.BackupCodes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Split(plaintext, ","), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBackupCode will check if the code is a backup code and burn it if so.
|
||||||
|
func (tf *TwoFactor) ValidateBackupCode(code string) bool {
|
||||||
|
var (
|
||||||
|
codes, err = tf.GetBackupCodes()
|
||||||
|
newCodes = []string{} // in case of burning one
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("ValidateBackupCode: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a match to our backup codes.
|
||||||
|
code = strings.ToLower(code)
|
||||||
|
var matched bool
|
||||||
|
for _, check := range codes {
|
||||||
|
if check == code {
|
||||||
|
// Successful match!
|
||||||
|
matched = true
|
||||||
|
} else {
|
||||||
|
newCodes = append(newCodes, check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found a match, burn the code.
|
||||||
|
if matched {
|
||||||
|
if err := tf.SetBackupCodes(newCodes); err != nil {
|
||||||
|
log.Error("ValidateBackupCode: SetBackupCodes: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save it to DB.
|
||||||
|
if err := tf.Save(); err != nil {
|
||||||
|
log.Error("ValidateBackupCode: saving changes to DB: %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// QRCodeAsDataURL returns an HTML img tag that embeds the 2FA QR code as a PNG data URL.
|
||||||
|
func (tf *TwoFactor) QRCodeAsDataURL(key *otp.Key) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
img, err := key.Image(200, 200)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
png.Encode(&buf, img)
|
||||||
|
|
||||||
|
var dataURL = fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
|
||||||
|
return fmt.Sprintf(`<img src="%s" alt="QR Code">`, dataURL), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the note.
|
||||||
|
func (tf *TwoFactor) Save() error {
|
||||||
|
log.Error("SAVE 2FA: %+v", tf)
|
||||||
|
if tf.isNew {
|
||||||
|
return DB.Create(tf).Error
|
||||||
|
}
|
||||||
|
return DB.Save(tf).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the DB entry.
|
||||||
|
func (tf *TwoFactor) Delete() error {
|
||||||
|
if tf.isNew {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DB.Delete(tf).Error
|
||||||
|
}
|
|
@ -45,6 +45,7 @@ func New() http.Handler {
|
||||||
mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
|
mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
|
||||||
mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
|
mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
|
||||||
mux.Handle("/settings/age-gate", middleware.LoginRequired(account.AgeGate()))
|
mux.Handle("/settings/age-gate", middleware.LoginRequired(account.AgeGate()))
|
||||||
|
mux.Handle("/account/two-factor/setup", middleware.LoginRequired(account.Setup2FA()))
|
||||||
mux.Handle("/account/delete", middleware.LoginRequired(account.Delete()))
|
mux.Handle("/account/delete", middleware.LoginRequired(account.Delete()))
|
||||||
mux.Handle("/u/", account.Profile()) // public access OK
|
mux.Handle("/u/", account.Profile()) // public access OK
|
||||||
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
|
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
|
||||||
|
|
|
@ -32,6 +32,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"Split": strings.Split,
|
"Split": strings.Split,
|
||||||
"ToMarkdown": ToMarkdown,
|
"ToMarkdown": ToMarkdown,
|
||||||
"ToJSON": ToJSON,
|
"ToJSON": ToJSON,
|
||||||
|
"ToHTML": ToHTML,
|
||||||
"PhotoURL": photo.URLPath,
|
"PhotoURL": photo.URLPath,
|
||||||
"Now": time.Now,
|
"Now": time.Now,
|
||||||
"PrettyTitle": func() template.HTML {
|
"PrettyTitle": func() template.HTML {
|
||||||
|
@ -93,6 +94,11 @@ func ToMarkdown(input string) template.HTML {
|
||||||
return template.HTML(markdown.Render(input))
|
return template.HTML(markdown.Render(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToHTML renders input text as trusted HTML code.
|
||||||
|
func ToHTML(input string) template.HTML {
|
||||||
|
return template.HTML(input)
|
||||||
|
}
|
||||||
|
|
||||||
// ToJSON will stringify any json-serializable object.
|
// ToJSON will stringify any json-serializable object.
|
||||||
func ToJSON(v any) template.JS {
|
func ToJSON(v any) template.JS {
|
||||||
bin, err := json.Marshal(v)
|
bin, err := json.Marshal(v)
|
||||||
|
|
|
@ -133,40 +133,32 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- New Feature: Who's Nearby -->
|
<!-- New Feature: Two Factor Auth -->
|
||||||
{{if not .MyLocation.Source}}
|
{{if not .TwoFactorEnabled}}
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-success-dark">
|
<header class="card-header has-background-success-dark">
|
||||||
<p class="card-header-title has-text-light">
|
<p class="card-header-title has-text-light">
|
||||||
<i class="fa fa-gift mr-2"></i>
|
<i class="fa fa-gift mr-2"></i>
|
||||||
New Feature: Who's Nearby?
|
New Feature: Two-Factor Authentication
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p class="block">
|
<p class="block">
|
||||||
We've recently added a new feature! <strong>Who's Nearby</strong> can allow you to sort the
|
We've recently added a new security feature you can use to better protect your account:
|
||||||
<a href="/members">Member Directory</a> by their distance away from you.
|
<strong>Two-Factor Authentication (2FA)!</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="block">
|
<p class="block">
|
||||||
First, you'll need to opt-in where <em>your</em> location is so that the site can know who's
|
This feature can help protect your account in case somebody finds out your password. When
|
||||||
nearby. Please visit your <a href="/settings#location">Location Settings</a> page to choose
|
enabled, you can use an authenticator app (such as Google Authenticator or Authy) to generate
|
||||||
how you share your location -- you can even just drop a pin on a map anywhere you're comfortable
|
a one-time use passcode to log in to your account.
|
||||||
with!
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="block">
|
<p class="block">
|
||||||
Then, you'll be able to <a href="/members?sort=distance">sort the Member Directory by distance</a>.
|
If interested, see your <a href="/account/two-factor/setup">Two Factor Auth</a> settings
|
||||||
Only people whose location is known will show in the results.
|
page to get started! This message will disappear after you have enrolled in 2FA, or after
|
||||||
</p>
|
a few weeks when enough people have had a chance to learn about the new feature.
|
||||||
|
|
||||||
<p class="block">
|
|
||||||
This feature is very privacy-conscious and you can turn it off again later, and we'll forget
|
|
||||||
any location data we had! For more information, see <a href="https://www.nonshy.com/forum/thread/161">this forum thread</a>.
|
|
||||||
This message will go away after you have set a <a href="/settings#location">location source</a>
|
|
||||||
for your profile -- or after a few weeks when enough people have had a chance to hear about
|
|
||||||
the new feature!
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,37 +11,109 @@
|
||||||
|
|
||||||
{{ $User := .CurrentUser }}
|
{{ $User := .CurrentUser }}
|
||||||
|
|
||||||
<div class="block p-4">
|
<div class="columns mt-4">
|
||||||
<div class="tabs is-boxed">
|
<div class="column is-one-quarter">
|
||||||
<ul>
|
<div class="card">
|
||||||
<li class="is-active">
|
<div class="card-header has-background-info">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<i class="fa fa-gear mr-2"></i>
|
||||||
|
Settings Menu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content p-4">
|
||||||
|
<p class="menu-label">
|
||||||
|
Settings
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="menu-list block">
|
||||||
|
<li>
|
||||||
<a href="/settings#profile" class="nonshy-tab-button">
|
<a href="/settings#profile" class="nonshy-tab-button">
|
||||||
Profile
|
<strong><i class="fa fa-address-card mr-1"></i> My Profile</strong>
|
||||||
|
<p class="help">Manage your profile page.</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings#prefs" class="nonshy-tab-button">
|
<a href="/settings#prefs" class="nonshy-tab-button">
|
||||||
Preferences
|
<strong><i class="fa fa-square-check mr-1"></i> Website Preferences</strong>
|
||||||
|
<p class="help">
|
||||||
|
Explicit content filter. <i class="fa fa-fire"></i>
|
||||||
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings#location" class="nonshy-tab-button">
|
<a href="/settings#location" class="nonshy-tab-button">
|
||||||
Location
|
<strong><i class="fa fa-globe mr-1"></i> Location Settings</strong>
|
||||||
|
<p class="help">
|
||||||
|
For the "Who's Nearby?" feature.
|
||||||
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a href="/settings#privacy" class="nonshy-tab-button">
|
||||||
|
<strong><i class="fa fa-eye mr-1"></i> Privacy</strong>
|
||||||
|
<p class="help">
|
||||||
|
Profile visibility; who can slide into your DMs.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings#account" class="nonshy-tab-button">
|
<a href="/settings#account" class="nonshy-tab-button">
|
||||||
Account
|
<strong><i class="fa fa-user mr-1"></i> Account Settings</strong>
|
||||||
|
<p class="help">
|
||||||
|
Change password or e-mail; Two-factor auth (2FA); delete account.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="menu-label">
|
||||||
|
See Also
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="menu-list block">
|
||||||
|
<li>
|
||||||
|
<a href="/users/blocked" class="nonshy-tab-button">
|
||||||
|
<strong><i class="fa fa-certificate mr-1"></i> Certification Photo</strong>
|
||||||
|
<p class="help">
|
||||||
|
View and manage your Certification status.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a href="/users/blocked" class="nonshy-tab-button">
|
||||||
|
<strong><i class="fa fa-eye mr-1"></i> Private Photos</strong>
|
||||||
|
<p class="help">
|
||||||
|
Manage who can see your private photos.
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a href="/users/blocked" class="nonshy-tab-button">
|
||||||
|
<strong><i class="fa fa-hand mr-1"></i> Blocked Users</strong>
|
||||||
|
<p class="help">
|
||||||
|
View and manage your block list.
|
||||||
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<div class="card" id="profile">
|
<div class="card" id="profile">
|
||||||
<header class="card-header has-background-link">
|
<header class="card-header has-background-link">
|
||||||
<p class="card-header-title has-text-light">
|
<p class="card-header-title has-text-light">
|
||||||
<i class="fa fa-user pr-2"></i>
|
<i class="fa fa-address-card pr-2"></i>
|
||||||
My Profile
|
My Profile
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
@ -226,7 +298,7 @@
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button type="submit" class="button is-primary">
|
<button type="submit" class="button is-primary">
|
||||||
Save Profile Settings
|
<i class="fa fa-save mr-2"></i> Save Profile Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -234,19 +306,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Website Preferences -->
|
<!-- Website Preferences -->
|
||||||
<form method="POST" action="/settings">
|
|
||||||
<input type="hidden" name="intent" value="preferences">
|
|
||||||
{{InputCSRF}}
|
|
||||||
|
|
||||||
<div class="card mb-5" id="prefs">
|
<div class="card mb-5" id="prefs">
|
||||||
<header class="card-header has-background-success">
|
<header class="card-header has-background-link">
|
||||||
<p class="card-header-title has-text-dark-dark">
|
<p class="card-header-title has-text-light">
|
||||||
<i class="fa fa-square-check pr-2"></i>
|
<i class="fa fa-square-check pr-2"></i>
|
||||||
Website Preferences
|
Website Preferences
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
<form method="POST" action="/settings">
|
||||||
|
<input type="hidden" name="intent" value="preferences">
|
||||||
|
{{InputCSRF}}
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Explicit Content Filter</label>
|
<label class="label">Explicit Content Filter</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
|
@ -263,147 +335,14 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Profile Visibility</label>
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="radio"
|
|
||||||
name="visibility"
|
|
||||||
value="public"
|
|
||||||
{{if eq .CurrentUser.Visibility "public"}}checked{{end}}>
|
|
||||||
Public + Login Required
|
|
||||||
<i class="fa fa-eye ml-2 has-text-info"></i>
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
The default is that users must be logged-in to even look at your profile
|
|
||||||
page. If your profile URL (/u/{{.CurrentUser.Username}}) is visited by a
|
|
||||||
logged-out browser, they are prompted to log in.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="checkbox mt-2">
|
|
||||||
<input type="radio"
|
|
||||||
name="visibility"
|
|
||||||
value="external"
|
|
||||||
{{if eq .CurrentUser.Visibility "external"}}checked{{end}}>
|
|
||||||
Public + Limited Logged-out View
|
|
||||||
<i class="fa fa-eye ml-2 has-text-danger"></i>
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
Your profile is fully visible to logged-in users, but if a logged-out browser
|
|
||||||
visits your page they will see a very minimal view: only your profile picture
|
|
||||||
and display name are shown.
|
|
||||||
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="checkbox mt-2">
|
|
||||||
<input type="radio"
|
|
||||||
name="visibility"
|
|
||||||
value="private"
|
|
||||||
{{if eq .CurrentUser.Visibility "private"}}checked{{end}}>
|
|
||||||
Mark my profile page as "private"
|
|
||||||
<i class="fa fa-lock ml-2 has-text-private"></i>
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
If you check this box then only friends who you have approved are able to
|
|
||||||
see your profile page and gallery. Your gallery photos also will NOT appear
|
|
||||||
on the Site Gallery page. If your profile page is visited by a logged-out
|
|
||||||
viewer, they are prompted to log in.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label class="label mb-0">Who can send me the first <i class="fa fa-envelope"></i> Message?</label>
|
|
||||||
|
|
||||||
<div class="has-text-info ml-4">
|
|
||||||
<small><em>
|
|
||||||
Note: this refers to Direct Messages on the main website
|
|
||||||
(not inside the chat room).
|
|
||||||
</em></small>
|
|
||||||
{{.CurrentUser.GetProfileField "dm_privacy"}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="radio"
|
|
||||||
name="dm_privacy"
|
|
||||||
value=""
|
|
||||||
{{if eq (.CurrentUser.GetProfileField "dm_privacy") ""}}checked{{end}}>
|
|
||||||
Anybody on the site
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
Almost any member of the site may send you a Direct Message from your profile
|
|
||||||
page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="radio"
|
|
||||||
name="dm_privacy"
|
|
||||||
value="friends"
|
|
||||||
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "friends"}}checked{{end}}>
|
|
||||||
Only people on my Friends list
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
Nobody can slide into your DMs except for friends (and admins if needed). Anybody
|
|
||||||
may <em>reply</em> to messages that you send to them.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="radio"
|
|
||||||
name="dm_privacy"
|
|
||||||
value="nobody"
|
|
||||||
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}>
|
|
||||||
Nobody (close my DMs)
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
Nobody can start a Direct Message conversation with you on the main website
|
|
||||||
(except an admin if necessary). Anybody may <em>reply</em> to messages that you
|
|
||||||
sent to them first.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TODO: manually opt-in dark mode is hairy, look at
|
|
||||||
those media queries in bulma-prefers-dark.js!
|
|
||||||
<div class="field">
|
|
||||||
<label class="label">Website Theme</label>
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="radio"
|
|
||||||
name="theme"
|
|
||||||
value=""
|
|
||||||
{{if eq ($User.GetProfileField "theme") "" }}checked{{end}}>
|
|
||||||
Automatic
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
Automatically chooses a theme based on your device settings.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="radio"
|
|
||||||
name="theme"
|
|
||||||
value="light"
|
|
||||||
{{if eq ($User.GetProfileField "theme") "light" }}checked{{end}}>
|
|
||||||
Light
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="radio"
|
|
||||||
name="theme"
|
|
||||||
value="dark"
|
|
||||||
{{if eq ($User.GetProfileField "theme") "dark" }}checked{{end}}>
|
|
||||||
Dark
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button type="submit" class="button is-primary">
|
<button type="submit" class="button is-primary">
|
||||||
Save Website Preferences
|
<i class="fa fa-save mr-2"></i> Save Website Preferences
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Location Settings -->
|
<!-- Location Settings -->
|
||||||
<div id="location">
|
<div id="location">
|
||||||
|
@ -533,6 +472,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button type="submit" class="button is-success mr-2"
|
<button type="submit" class="button is-success mr-2"
|
||||||
name="intent" value="location">
|
name="intent" value="location">
|
||||||
|
<i class="fa fa-save mr-2"></i>
|
||||||
Save My Location Settings
|
Save My Location Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -556,21 +496,187 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Settings -->
|
<!-- Privacy Settings -->
|
||||||
<div id="account">
|
<div class="card mb-5" id="privacy">
|
||||||
<form method="POST" action="/settings">
|
<header class="card-header has-background-success">
|
||||||
<input type="hidden" name="intent" value="settings">
|
|
||||||
{{InputCSRF}}
|
|
||||||
|
|
||||||
<div class="card mb-5">
|
|
||||||
<header class="card-header has-background-warning">
|
|
||||||
<p class="card-header-title has-text-dark-dark">
|
<p class="card-header-title has-text-dark-dark">
|
||||||
<i class="fa fa-gear pr-2"></i>
|
<i class="fa fa-square-check pr-2"></i>
|
||||||
Account Settings
|
Privacy Settings
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
<form method="POST" action="/settings">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="intent" value="privacy">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Profile Visibility</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="public"
|
||||||
|
{{if eq .CurrentUser.Visibility "public"}}checked{{end}}>
|
||||||
|
Public + Login Required
|
||||||
|
<i class="fa fa-eye ml-2 has-text-info"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
The default is that users must be logged-in to even look at your profile
|
||||||
|
page. If your profile URL (/u/{{.CurrentUser.Username}}) is visited by a
|
||||||
|
logged-out browser, they are prompted to log in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox mt-2">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="external"
|
||||||
|
{{if eq .CurrentUser.Visibility "external"}}checked{{end}}>
|
||||||
|
Public + Limited Logged-out View
|
||||||
|
<i class="fa fa-eye ml-2 has-text-danger"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Your profile is fully visible to logged-in users, but if a logged-out browser
|
||||||
|
visits your page they will see a very minimal view: only your profile picture
|
||||||
|
and display name are shown.
|
||||||
|
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox mt-2">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="private"
|
||||||
|
{{if eq .CurrentUser.Visibility "private"}}checked{{end}}>
|
||||||
|
Mark my profile page as "private"
|
||||||
|
<i class="fa fa-lock ml-2 has-text-private"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
If you check this box then only friends who you have approved are able to
|
||||||
|
see your profile page and gallery. Your gallery photos also will NOT appear
|
||||||
|
on the Site Gallery page. If your profile page is visited by a logged-out
|
||||||
|
viewer, they are prompted to log in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label mb-0">Who can send me the first <i class="fa fa-envelope"></i> Message?</label>
|
||||||
|
|
||||||
|
<div class="has-text-info ml-4">
|
||||||
|
<small><em>
|
||||||
|
Note: this refers to Direct Messages on the main website
|
||||||
|
(not inside the chat room).
|
||||||
|
</em></small>
|
||||||
|
{{.CurrentUser.GetProfileField "dm_privacy"}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="dm_privacy"
|
||||||
|
value=""
|
||||||
|
{{if eq (.CurrentUser.GetProfileField "dm_privacy") ""}}checked{{end}}>
|
||||||
|
Anybody on the site
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Almost any member of the site may send you a Direct Message from your profile
|
||||||
|
page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="dm_privacy"
|
||||||
|
value="friends"
|
||||||
|
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "friends"}}checked{{end}}>
|
||||||
|
Only people on my Friends list
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Nobody can slide into your DMs except for friends (and admins if needed). Anybody
|
||||||
|
may <em>reply</em> to messages that you send to them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="radio"
|
||||||
|
name="dm_privacy"
|
||||||
|
value="nobody"
|
||||||
|
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}>
|
||||||
|
Nobody (close my DMs)
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Nobody can start a Direct Message conversation with you on the main website
|
||||||
|
(except an admin if necessary). Anybody may <em>reply</em> to messages that you
|
||||||
|
sent to them first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<button type="submit" class="button is-primary">
|
||||||
|
<i class="fa fa-save mr-2"></i> Save Privacy Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Settings -->
|
||||||
|
<div id="account">
|
||||||
|
<div class="card mb-5">
|
||||||
|
<header class="card-header has-background-link">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<i class="fa fa-lock pr-2"></i>
|
||||||
|
Two-Factor Authentication
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content content">
|
||||||
|
<p>
|
||||||
|
To help protect your {{PrettyTitle}} account, you may opt-in to add a
|
||||||
|
second factor to your login ("Two-Factor Authentication", or 2FA). This
|
||||||
|
means that in addition to needing "something you know" (your password) to
|
||||||
|
log in to your account, you can also require "something you have" (an
|
||||||
|
authenticator device which generates random time-dependent codes).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{PrettyTitle}} offers Two-Factor Authentication using the industry
|
||||||
|
standard "Time-based One-Time Password" (TOTP) system that is compatible
|
||||||
|
with Google Authenticator and Authy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Your Two-Factor is currently:
|
||||||
|
{{if .TwoFactorEnabled}}
|
||||||
|
<i class="fa fa-check mr-1 has-text-success-dark"></i>
|
||||||
|
<strong class="has-text-success-dark">Enabled</strong>
|
||||||
|
{{else}}
|
||||||
|
<i class="fa fa-xmark mr-1 has-text-danger"></i>
|
||||||
|
<strong class="has-text-danger">Not Enabled</strong>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="/account/two-factor/setup" class="button is-primary">
|
||||||
|
Manage Two-Factor Authentication
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-5">
|
||||||
|
<header class="card-header has-background-link">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<i class="fa fa-lock pr-2"></i>
|
||||||
|
Update E-mail or Password
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<form method="POST" action="/settings">
|
||||||
|
<input type="hidden" name="intent" value="settings">
|
||||||
|
{{InputCSRF}}
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="old_password">
|
<label class="label" for="old_password">
|
||||||
Current Password
|
Current Password
|
||||||
|
@ -606,18 +712,18 @@
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button type="submit" class="button is-primary">
|
<button type="submit" class="button is-primary">
|
||||||
Save Account Settings
|
<i class="fa fa-save mr-2"></i> Save Account Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Account -->
|
<!-- Delete Account -->
|
||||||
<div class="card mb-5">
|
<div class="card mb-5">
|
||||||
<header class="card-header has-background-danger">
|
<header class="card-header has-background-danger">
|
||||||
<p class="card-header-title has-text-light">
|
<p class="card-header-title has-text-light">
|
||||||
<i class="fa fa-gear pr-2"></i>
|
<i class="fa fa-exclamation-triangle pr-2"></i>
|
||||||
Delete Account
|
Delete Account
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
@ -636,7 +742,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -649,6 +755,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
const $profile = document.querySelector("#profile"),
|
const $profile = document.querySelector("#profile"),
|
||||||
$prefs = document.querySelector("#prefs"),
|
$prefs = document.querySelector("#prefs"),
|
||||||
$location = document.querySelector("#location"),
|
$location = document.querySelector("#location"),
|
||||||
|
$privacy = document.querySelector("#privacy"),
|
||||||
$account = document.querySelector("#account"),
|
$account = document.querySelector("#account"),
|
||||||
buttons = Array.from(document.getElementsByClassName("nonshy-tab-button"));
|
buttons = Array.from(document.getElementsByClassName("nonshy-tab-button"));
|
||||||
|
|
||||||
|
@ -656,6 +763,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
$profile.style.display = 'none';
|
$profile.style.display = 'none';
|
||||||
$prefs.style.display = 'none';
|
$prefs.style.display = 'none';
|
||||||
$location.style.display = 'none';
|
$location.style.display = 'none';
|
||||||
|
$privacy.style.display = 'none';
|
||||||
$account.style.display = 'none';
|
$account.style.display = 'none';
|
||||||
|
|
||||||
// Current tab to select by default.
|
// Current tab to select by default.
|
||||||
|
@ -673,6 +781,9 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
case "location":
|
case "location":
|
||||||
$activeTab = $location;
|
$activeTab = $location;
|
||||||
break;
|
break;
|
||||||
|
case "privacy":
|
||||||
|
$activeTab = $privacy;
|
||||||
|
break;
|
||||||
case "account":
|
case "account":
|
||||||
$activeTab = $account;
|
$activeTab = $account;
|
||||||
break;
|
break;
|
||||||
|
@ -682,15 +793,14 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
|
||||||
// Update the is-active classes on all the tabs.
|
// Update the is-active classes on all the tabs.
|
||||||
buttons.forEach(tab_ => {
|
buttons.forEach(tab_ => {
|
||||||
let name_ = tab_.href.split("#").pop(),
|
let name_ = tab_.href.split("#").pop();
|
||||||
parent = tab_.parentElement;
|
|
||||||
|
|
||||||
if (name !== name_) {
|
if (name !== name_) {
|
||||||
console.log("button: remove is-active", tab_);
|
console.log("button: remove is-active", tab_);
|
||||||
parent.classList.remove("is-active");
|
tab_.classList.remove("is-active");
|
||||||
} else {
|
} else {
|
||||||
console.log("button %s: ADD is-active", tab_);
|
console.log("button %s: ADD is-active", tab_);
|
||||||
parent.classList.add("is-active");
|
tab_.classList.add("is-active");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
62
web/templates/account/two_factor_login.html
Normal file
62
web/templates/account/two_factor_login.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
{{define "title"}}Two-Factor Authentication{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero is-info is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
<i class="fa fa-lock mr-2"></i>
|
||||||
|
Two-Factor Authentication
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ $User := .CurrentUser }}
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="columns is-centered">
|
||||||
|
<div class="column is-half">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header has-background-link">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<i class="fa fa-lock mr-2"></i>
|
||||||
|
Two-Factor Authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content content">
|
||||||
|
<form action="{{.Request.URL.Path}}" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="intent" value="two-factor">
|
||||||
|
<input type="hidden" name="username" value="{{.Username}}">
|
||||||
|
<input type="hidden" name="password" value="{{.Password}}">
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please enter the six-digit code from your Authenticator App. If you have
|
||||||
|
lost access to your authenticator device, you may enter one of your
|
||||||
|
Backup Codes here instead.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<input type="text" class="input"
|
||||||
|
placeholder="000000"
|
||||||
|
name="code"
|
||||||
|
maxlength="8"
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button type="submit" class="button is-primary">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
231
web/templates/account/two_factor_setup.html
Normal file
231
web/templates/account/two_factor_setup.html
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
{{define "title"}}Two-Factor Authentication{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero is-info is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
<i class="fa fa-lock mr-2"></i>
|
||||||
|
Two-Factor Authentication
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ $User := .CurrentUser }}
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<!-- Currently Enabled screen -->
|
||||||
|
{{if .TwoFactor.Enabled}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header has-background-link">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<i class="fa fa-lock mr-2"></i>
|
||||||
|
Two-Factor Authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
Two-Factor status:
|
||||||
|
<i class="fa fa-check mr-1 has-text-success-dark"></i>
|
||||||
|
<strong class="has-text-success-dark">Enabled!</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
When you next log in to your account, you will need your Authenticator App handy to produce
|
||||||
|
the time-limited six-digit code to log in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Backup Codes</h4>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
In case you lose access to your Authenticator App, please print off or write down these
|
||||||
|
<strong>backup codes</strong> which will allow you to re-gain access to your {{PrettyTitle}}
|
||||||
|
account. Each of these codes may be used <strong>one time</strong> in response to your
|
||||||
|
2FA Authenticator prompt at login.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="columns is-multiline is-mobile">
|
||||||
|
{{range .TwoFactor.GetBackupCodes}}
|
||||||
|
<div class="column is-one-third-mobile is-one-quarter-tablet">
|
||||||
|
<code>{{.}}</code>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you would like to <strong>re-generate</strong> these backup codes, click on the button below. This may
|
||||||
|
be useful if you have needed to log in using these codes (which are one-time use only) and wish to generate
|
||||||
|
a fresh set of backup codes. Note that re-generating new codes will cause the old ones to no longer work!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Form to regenerate backup codes. -->
|
||||||
|
<form action="{{.Request.URL.Path}}" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="intent" value="regenerate-backup-codes">
|
||||||
|
<button type="submit" class="button is-warning"
|
||||||
|
onclick="return window.confirm('Are you sure you want to re-generate all of your Backup Codes? This will remove the current set of Backup Codes and replace them with a new set.')">
|
||||||
|
<i class="fa fa-rotate mr-2"></i>
|
||||||
|
Generate all-new backup codes
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4 class="has-text-danger mt-4">Disable Two-Factor Auth</h4>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you wish to <strong>disable</strong> two-factor authentication for your account, please enter
|
||||||
|
your account password for verification and click on the button below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="{{.Request.URL.Path}}" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="intent" value="disable">
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="password">Your account password:</label>
|
||||||
|
<input type="password" class="input"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button is-danger">
|
||||||
|
Disable Two-Factor Authentication
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}<!-- Currently Enabled -->
|
||||||
|
|
||||||
|
<!-- Set Up screen -->
|
||||||
|
{{if not .TwoFactor.Enabled}}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header has-background-link">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
<i class="fa fa-lock mr-2"></i>
|
||||||
|
Set Up Two-Factor Authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
Two-Factor status:
|
||||||
|
<i class="fa fa-xmark mr-1 has-text-danger"></i>
|
||||||
|
<strong class="has-text-danger">Disabled!</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Set up your Authenticator App</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To set up Two-Factor Auth, you'll need to download and install a compatible
|
||||||
|
Authenticator App on your device. Some suggestions for apps that are compatible
|
||||||
|
with {{PrettyTitle}} are as follows:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Google Authenticator:</strong>
|
||||||
|
for <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">
|
||||||
|
<i class="fab fa-android"></i> Android
|
||||||
|
</a> or <a href="https://apps.apple.com/us/app/google-authenticator/id388497605" target="_blank">
|
||||||
|
<i class="fab fa-apple"></i> iOS
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
<a href="https://authy.com/download/" target="_blank">Authy:</a>
|
||||||
|
</strong>
|
||||||
|
available for <i class="fab fa-android"></i> Android and <i class="fab fa-apple"></i> iOS
|
||||||
|
as well as Windows, macOS and Linux computers.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Add {{PrettyTitle}} to your Authenticator App</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
When you have your Authenticator App ready, click on its "Add a new site" button and scan
|
||||||
|
the following QR code to enroll your device for {{PrettyTitle}}:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{ToHTML .QRCode}}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Alternatively (if you can't scan the QR code), you may copy and paste this secret text
|
||||||
|
in to your Authenticator app:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
<div class="column is-half-tablet pr-1">
|
||||||
|
<input type="text" class="input"
|
||||||
|
id="totp-secret"
|
||||||
|
value="{{.Key.Secret}}"
|
||||||
|
readonly
|
||||||
|
onclick="copySecret()">
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow pl-0">
|
||||||
|
<button type="button"
|
||||||
|
class="button is-success"
|
||||||
|
id="copy-button"
|
||||||
|
onclick="copySecret()">
|
||||||
|
<i class="fa fa-copy mr-1"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Test your Authenticator App</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
After scanning the QR code (or copying the secret key) into your Authenticator app, you
|
||||||
|
should be able to generate temporary six-digit authentication codes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Test that you have enrolled your authenticator correctly by entering the current six-digit
|
||||||
|
code below:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="{{.Request.URL.Path}}" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="intent" value="setup-verify">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="code">
|
||||||
|
Authentication Code:
|
||||||
|
</label>
|
||||||
|
<input type="text" class="input is-one-quarter"
|
||||||
|
name="code" id="code"
|
||||||
|
pattern="^[0-9]{6}$"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="000000"
|
||||||
|
style="max-width: 12em"
|
||||||
|
autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button is-primary">Confirm & Continue</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}<!-- Setup -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
function copySecret() {
|
||||||
|
const secret = document.querySelector("#totp-secret")
|
||||||
|
copyButton = document.querySelector("#copy-button");
|
||||||
|
secret.select();
|
||||||
|
navigator.clipboard.writeText(secret.value);
|
||||||
|
copyButton.innerHTML = `<i class="fa fa-check mr-1"></i> Copied!`;
|
||||||
|
setTimeout(() => {
|
||||||
|
copyButton.innerHTML = `<i class="fa fa-copy mr-1"></i> Copy`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
Loading…
Reference in New Issue
Block a user