Two Factor Authentication

face-detect
Noah Petherbridge 2023-09-18 17:22:50 -07:00
parent 41beba54f2
commit c3a3b7e35c
19 changed files with 1719 additions and 611 deletions

2
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View 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
}
})
}

View 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
}

View 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
}

View 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
}

View File

@ -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
View 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
}

View File

@ -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()))

View File

@ -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)

View File

@ -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>

View File

@ -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");
}
});

View 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}}

View 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 &amp; 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}}