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)
|
||||
|
||||
|
|
|
@ -122,11 +122,23 @@ func Settings() http.HandlerFunc {
|
|||
case "preferences":
|
||||
hashtag = "#prefs"
|
||||
var (
|
||||
explicit = r.PostFormValue("explicit") == "true"
|
||||
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
||||
explicit = r.PostFormValue("explicit") == "true"
|
||||
)
|
||||
|
||||
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>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
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