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

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

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>

File diff suppressed because it is too large Load Diff

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