Two Factor Authentication
This commit is contained in:
parent
41beba54f2
commit
c3a3b7e35c
2
go.mod
2
go.mod
|
@ -15,6 +15,7 @@ require (
|
|||
|
||||
require (
|
||||
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/cpuguy83/go-md2man/v2 v2.0.2 // 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/oschwald/geoip2-golang v1.9.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/v2 v2.1.0 // 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/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/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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/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/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/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
|
|
|
@ -35,6 +35,9 @@ const (
|
|||
SessionRedisKeyFormat = "session/%s"
|
||||
MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs)
|
||||
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
|
||||
|
||||
TwoFactorBackupCodeCount = 12
|
||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||
)
|
||||
|
||||
// Authentication
|
||||
|
|
|
@ -7,13 +7,14 @@ import (
|
|||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Version of the config format - when new fields are added, it will attempt
|
||||
// to write the settings.toml to disk so new defaults populate.
|
||||
var currentVersion = 1
|
||||
var currentVersion = 2
|
||||
|
||||
// Current loaded settings.json
|
||||
var Current = DefaultVariable()
|
||||
|
@ -29,6 +30,7 @@ type Variable struct {
|
|||
Database Database
|
||||
BareRTC BareRTC
|
||||
Maintenance Maintenance
|
||||
Encryption Encryption
|
||||
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.
|
||||
func LoadSettings() {
|
||||
var writeSettings bool
|
||||
|
||||
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
|
||||
log.Info("Loading settings from %s", SettingsPath)
|
||||
content, err := ioutil.ReadFile(SettingsPath)
|
||||
|
@ -81,8 +85,20 @@ func LoadSettings() {
|
|||
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.
|
||||
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.")
|
||||
Current.Version = currentVersion
|
||||
if err := WriteSettings(); err != nil {
|
||||
|
@ -144,3 +160,8 @@ type Maintenance struct {
|
|||
PauseChat bool
|
||||
PauseInteraction bool
|
||||
}
|
||||
|
||||
// Encryption settings.
|
||||
type Encryption struct {
|
||||
AESKey []byte
|
||||
}
|
||||
|
|
|
@ -96,6 +96,9 @@ func Dashboard() http.HandlerFunc {
|
|||
|
||||
// Who's Nearby stats.
|
||||
"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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
// Login controller.
|
||||
func Login() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/login.html")
|
||||
tmpl2fa := templates.Must("account/two_factor_login.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var next = r.FormValue("next")
|
||||
|
||||
|
@ -75,6 +76,46 @@ func Login() http.HandlerFunc {
|
|||
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.
|
||||
session.LoginUser(w, r, user)
|
||||
|
||||
|
|
|
@ -123,10 +123,22 @@ func Settings() http.HandlerFunc {
|
|||
hashtag = "#prefs"
|
||||
var (
|
||||
explicit = r.PostFormValue("explicit") == "true"
|
||||
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
for _, cmp := range models.UserVisibilityOptions {
|
||||
|
@ -136,13 +148,13 @@ func Settings() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Set profile field prefs.
|
||||
user.SetProfileField("dm_privacy", r.PostFormValue("dm_privacy"))
|
||||
user.SetProfileField("dm_privacy", dmPrivacy)
|
||||
|
||||
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!")
|
||||
session.Flash(w, r, "Privacy settings updated!")
|
||||
case "location":
|
||||
hashtag = "#location"
|
||||
var (
|
||||
|
@ -269,6 +281,9 @@ func Settings() http.HandlerFunc {
|
|||
vars["GeoIPInsights"] = insights
|
||||
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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
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(&UserLocation{})
|
||||
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("/settings", middleware.LoginRequired(account.Settings()))
|
||||
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("/u/", account.Profile()) // public access OK
|
||||
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
|
||||
|
|
|
@ -32,6 +32,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
"Split": strings.Split,
|
||||
"ToMarkdown": ToMarkdown,
|
||||
"ToJSON": ToJSON,
|
||||
"ToHTML": ToHTML,
|
||||
"PhotoURL": photo.URLPath,
|
||||
"Now": time.Now,
|
||||
"PrettyTitle": func() template.HTML {
|
||||
|
@ -93,6 +94,11 @@ func ToMarkdown(input string) template.HTML {
|
|||
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.
|
||||
func ToJSON(v any) template.JS {
|
||||
bin, err := json.Marshal(v)
|
||||
|
|
|
@ -133,40 +133,32 @@
|
|||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- New Feature: Who's Nearby -->
|
||||
{{if not .MyLocation.Source}}
|
||||
<!-- New Feature: Two Factor Auth -->
|
||||
{{if not .TwoFactorEnabled}}
|
||||
<div class="card block">
|
||||
<header class="card-header has-background-success-dark">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-gift mr-2"></i>
|
||||
New Feature: Who's Nearby?
|
||||
New Feature: Two-Factor Authentication
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<p class="block">
|
||||
We've recently added a new feature! <strong>Who's Nearby</strong> can allow you to sort the
|
||||
<a href="/members">Member Directory</a> by their distance away from you.
|
||||
We've recently added a new security feature you can use to better protect your account:
|
||||
<strong>Two-Factor Authentication (2FA)!</strong>
|
||||
</p>
|
||||
|
||||
<p class="block">
|
||||
First, you'll need to opt-in where <em>your</em> location is so that the site can know who's
|
||||
nearby. Please visit your <a href="/settings#location">Location Settings</a> page to choose
|
||||
how you share your location -- you can even just drop a pin on a map anywhere you're comfortable
|
||||
with!
|
||||
This feature can help protect your account in case somebody finds out your password. When
|
||||
enabled, you can use an authenticator app (such as Google Authenticator or Authy) to generate
|
||||
a one-time use passcode to log in to your account.
|
||||
</p>
|
||||
|
||||
<p class="block">
|
||||
Then, you'll be able to <a href="/members?sort=distance">sort the Member Directory by distance</a>.
|
||||
Only people whose location is known will show in the results.
|
||||
</p>
|
||||
|
||||
<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!
|
||||
If interested, see your <a href="/account/two-factor/setup">Two Factor Auth</a> settings
|
||||
page to get started! This message will disappear after you have enrolled in 2FA, or after
|
||||
a few weeks when enough people have had a chance to learn about the new feature.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,37 +11,109 @@
|
|||
|
||||
{{ $User := .CurrentUser }}
|
||||
|
||||
<div class="block p-4">
|
||||
<div class="tabs is-boxed">
|
||||
<ul>
|
||||
<li class="is-active">
|
||||
<div class="columns mt-4">
|
||||
<div class="column is-one-quarter">
|
||||
<div class="card">
|
||||
<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">
|
||||
Profile
|
||||
<strong><i class="fa fa-address-card mr-1"></i> My Profile</strong>
|
||||
<p class="help">Manage your profile page.</p>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<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>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
|
||||
<!-- Profile -->
|
||||
<div class="card" id="profile">
|
||||
<header class="card-header has-background-link">
|
||||
<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
|
||||
</p>
|
||||
</header>
|
||||
|
@ -226,7 +298,7 @@
|
|||
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-primary">
|
||||
Save Profile Settings
|
||||
<i class="fa fa-save mr-2"></i> Save Profile Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -234,19 +306,19 @@
|
|||
</div>
|
||||
|
||||
<!-- Website Preferences -->
|
||||
<form method="POST" action="/settings">
|
||||
<input type="hidden" name="intent" value="preferences">
|
||||
{{InputCSRF}}
|
||||
|
||||
<div class="card mb-5" id="prefs">
|
||||
<header class="card-header has-background-success">
|
||||
<p class="card-header-title has-text-dark-dark">
|
||||
<header class="card-header has-background-link">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-square-check pr-2"></i>
|
||||
Website Preferences
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<form method="POST" action="/settings">
|
||||
<input type="hidden" name="intent" value="preferences">
|
||||
{{InputCSRF}}
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Explicit Content Filter</label>
|
||||
<label class="checkbox">
|
||||
|
@ -263,147 +335,14 @@
|
|||
</p>
|
||||
</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">
|
||||
<button type="submit" class="button is-primary">
|
||||
Save Website Preferences
|
||||
<i class="fa fa-save mr-2"></i> Save Website Preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Settings -->
|
||||
<div id="location">
|
||||
|
@ -533,6 +472,7 @@
|
|||
<div class="field">
|
||||
<button type="submit" class="button is-success mr-2"
|
||||
name="intent" value="location">
|
||||
<i class="fa fa-save mr-2"></i>
|
||||
Save My Location Settings
|
||||
</button>
|
||||
</div>
|
||||
|
@ -556,21 +496,187 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Account Settings -->
|
||||
<div id="account">
|
||||
<form method="POST" action="/settings">
|
||||
<input type="hidden" name="intent" value="settings">
|
||||
{{InputCSRF}}
|
||||
|
||||
<div class="card mb-5">
|
||||
<header class="card-header has-background-warning">
|
||||
<!-- Privacy Settings -->
|
||||
<div class="card mb-5" id="privacy">
|
||||
<header class="card-header has-background-success">
|
||||
<p class="card-header-title has-text-dark-dark">
|
||||
<i class="fa fa-gear pr-2"></i>
|
||||
Account Settings
|
||||
<i class="fa fa-square-check pr-2"></i>
|
||||
Privacy Settings
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<label class="label" for="old_password">
|
||||
Current Password
|
||||
|
@ -606,18 +712,18 @@
|
|||
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-primary">
|
||||
Save Account Settings
|
||||
<i class="fa fa-save mr-2"></i> Save Account Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Account -->
|
||||
<div class="card mb-5">
|
||||
<header class="card-header has-background-danger">
|
||||
<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
|
||||
</p>
|
||||
</header>
|
||||
|
@ -636,7 +742,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
@ -649,6 +755,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
|||
const $profile = document.querySelector("#profile"),
|
||||
$prefs = document.querySelector("#prefs"),
|
||||
$location = document.querySelector("#location"),
|
||||
$privacy = document.querySelector("#privacy"),
|
||||
$account = document.querySelector("#account"),
|
||||
buttons = Array.from(document.getElementsByClassName("nonshy-tab-button"));
|
||||
|
||||
|
@ -656,6 +763,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
|||
$profile.style.display = 'none';
|
||||
$prefs.style.display = 'none';
|
||||
$location.style.display = 'none';
|
||||
$privacy.style.display = 'none';
|
||||
$account.style.display = 'none';
|
||||
|
||||
// Current tab to select by default.
|
||||
|
@ -673,6 +781,9 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
|||
case "location":
|
||||
$activeTab = $location;
|
||||
break;
|
||||
case "privacy":
|
||||
$activeTab = $privacy;
|
||||
break;
|
||||
case "account":
|
||||
$activeTab = $account;
|
||||
break;
|
||||
|
@ -682,15 +793,14 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
|||
|
||||
// Update the is-active classes on all the tabs.
|
||||
buttons.forEach(tab_ => {
|
||||
let name_ = tab_.href.split("#").pop(),
|
||||
parent = tab_.parentElement;
|
||||
let name_ = tab_.href.split("#").pop();
|
||||
|
||||
if (name !== name_) {
|
||||
console.log("button: remove is-active", tab_);
|
||||
parent.classList.remove("is-active");
|
||||
tab_.classList.remove("is-active");
|
||||
} else {
|
||||
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