diff --git a/go.mod b/go.mod index e5b1971..03dcd3c 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 8243f6f..35d2b27 100644 --- a/go.sum +++ b/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= diff --git a/pkg/config/config.go b/pkg/config/config.go index 82b3df0..b5f9cf8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 17bd5ab..7f4ebe4 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -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 +} diff --git a/pkg/controller/account/dashboard.go b/pkg/controller/account/dashboard.go index 5ac41d9..ffb81e5 100644 --- a/pkg/controller/account/dashboard.go +++ b/pkg/controller/account/dashboard.go @@ -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) diff --git a/pkg/controller/account/login.go b/pkg/controller/account/login.go index 3bfc98a..4f3a87d 100644 --- a/pkg/controller/account/login.go +++ b/pkg/controller/account/login.go @@ -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) diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index ef3a233..7fff97b 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -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 diff --git a/pkg/controller/account/two_factor.go b/pkg/controller/account/two_factor.go new file mode 100644 index 0000000..d82e2ff --- /dev/null +++ b/pkg/controller/account/two_factor.go @@ -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 + } + }) +} diff --git a/pkg/encryption/encryption.go b/pkg/encryption/encryption.go new file mode 100644 index 0000000..8d6e1ed --- /dev/null +++ b/pkg/encryption/encryption.go @@ -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 +} diff --git a/pkg/encryption/encryption_test.go b/pkg/encryption/encryption_test.go new file mode 100644 index 0000000..74ab1c5 --- /dev/null +++ b/pkg/encryption/encryption_test.go @@ -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 +} diff --git a/pkg/encryption/keygen/keygen.go b/pkg/encryption/keygen/keygen.go new file mode 100644 index 0000000..009aca0 --- /dev/null +++ b/pkg/encryption/keygen/keygen.go @@ -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 +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 953fb5c..b04d276 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -30,4 +30,5 @@ func AutoMigrate() { DB.AutoMigrate(&AdminScope{}) DB.AutoMigrate(&UserLocation{}) DB.AutoMigrate(&UserNote{}) + DB.AutoMigrate(&TwoFactor{}) } diff --git a/pkg/models/two_factor.go b/pkg/models/two_factor.go new file mode 100644 index 0000000..c570fc4 --- /dev/null +++ b/pkg/models/two_factor.go @@ -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(`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 +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 9109fd1..3e4cfb5 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -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())) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index fc1e8ce..f2e82df 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -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) diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 84ec34d..b956dd1 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -133,40 +133,32 @@ {{end}} - - {{if not .MyLocation.Source}} + + {{if not .TwoFactorEnabled}}

- New Feature: Who's Nearby? + New Feature: Two-Factor Authentication

- We've recently added a new feature! Who's Nearby can allow you to sort the - Member Directory by their distance away from you. + We've recently added a new security feature you can use to better protect your account: + Two-Factor Authentication (2FA)!

- First, you'll need to opt-in where your location is so that the site can know who's - nearby. Please visit your Location Settings 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.

- Then, you'll be able to sort the Member Directory by distance. - Only people whose location is known will show in the results. -

- -

- 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 this forum thread. - This message will go away after you have set a location source - 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 Two Factor Auth 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.

diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index 87720e4..b5a4df6 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -11,632 +11,738 @@ {{ $User := .CurrentUser }} -
-
- -
+
+
+
+
+

+ + Settings Menu +

+
- -
- - -
- - {{InputCSRF}} - -
-

- The fields here are shown on your profile page - and are all optional. Fields with a icon are not shown on - your page but may drive some data that is (e.g., your current age derived from your birthdate). +

+ -
-
- - -
+
+
  • + + Location Settings +

    + For the "Who's Nearby?" feature. +

    +
    +
  • -
    -
    - -
    - -
    -
    +
  • + + Privacy +

    + Profile visibility; who can slide into your DMs. +

    +
    +
  • -
    - - -

    e.g. - he/him; - she/her; - they/them -

    -
    -
    +
  • + + Account Settings +

    + Change password or e-mail; Two-factor auth (2FA); delete account. +

    +
    +
  • + -
    -
    - - -
    + +
    +
  • + + Private Photos +

    + Manage who can see your private photos. +

    +
    +
  • -
    -
    - -
    - -
    -
    - -
    - -
    - -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    - -
    - -
    - {{range .Enum.HereFor}} -
    - -
    - {{end}} -
    -
    - -
    - - -

    - Write a bit about yourself. Markdown formatting is supported here. -

    -
    - -
    - - -
    - -
    - - -
    - -
    - -
    +
  • + + Blocked Users +

    + View and manage your block list. +

    +
    +
  • +
    - +
    - -
    - - {{InputCSRF}} +
    + +
    + + + + + {{InputCSRF}} + +
    +

    + The fields here are shown on your profile page + and are all optional. Fields with a icon are not shown on + your page but may drive some data that is (e.g., your current age derived from your birthdate). +

    + +
    +
    + + +
    + +
    + + +

    + Used to show your age on your profile. + {{if not $User.Birthdate.IsZero}} + If you entered a wrong birthdate, you can change it here. Note that all birthdate + changes will notify the admin, so don't mess around or behave dishonestly. + {{end}} +

    + + + +
    +
    + +
    +
    + +
    + +
    +
    + +
    + + +

    e.g. + he/him; + she/her; + they/them +

    +
    +
    + +
    +
    + + +
    + + +
    + + +
    +
    + +
    +
    + +
    + +
    +
    + +
    + +
    + +
    +
    +
    + +
    +
    + +
    + +
    +
    +
    + +
    + +
    + {{range .Enum.HereFor}} +
    + +
    + {{end}} +
    +
    + +
    + + +

    + Write a bit about yourself. Markdown formatting is supported here. +

    +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    + +
    + +
    -
    -

    +

    -
    - - -

    - Check this box if you are OK seeing explicit content on this site, which may - include erections or sexually charged content. These may appear on the Site - Gallery as well as user profile pages. -

    -
    - -
    - -
    - - -

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

    - - -

    - 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. - Preview -

    - - -

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

    -
    - -
    - -
    - - -
    - - Note: this refers to Direct Messages on the main website - (not inside the chat room). - - {{.CurrentUser.GetProfileField "dm_privacy"}} -
    - - -

    - Almost any member of the site may send you a Direct Message from your profile - page (except for maybe Shy Accounts). -

    - - -

    - Nobody can slide into your DMs except for friends (and admins if needed). Anybody - may reply to messages that you send to them. -

    - - -

    - Nobody can start a Direct Message conversation with you on the main website - (except an admin if necessary). Anybody may reply to messages that you - sent to them first. -

    -
    - - - -
    - -
    -
    -
    - - - -
    -
    - - {{InputCSRF}} - -
    - - -
    -

    - The settings on this page control your location for the Who's Nearby - feature of {{PrettyTitle}}. Being discoverable by your location is an opt-in - feature and you have your choice of options how you want your location to be found. -

    - -
    - - Notice: - Remember to click "Save" after setting your location preference! -
    + + + {{InputCSRF}}
    - + -

    - This option will opt-out of the Who's Nearby feature and your profile will not be - discoverable by distance to other members. Any location data already stored by - the website will be erased if you choose this option. -

    - - -

    - Coarse location data based on your IP address. Might be accurate to your - city level. - - - {{if not .GeoIPInsights.IsZero}} -
    - Currently: - {{.GeoIPInsights}} - {{end}} -

    - - -

    - Your web browser will prompt you to share your current location with {{PrettyTitle}}. -
    - Currently: - You have not granted permission. - -

    - - -

    - Use the map below and drop a pin anywhere you like to set your location. -

    -
    - -
    - -

    - Your location will be saved as the following in the database: -

    -
    -
    - - -
    -
    - - -
    -
    -
    - -
    - -
    - -

    Map

    - -

    - This map shows your current location pin. To click and drop a pin manually, - select the "Drop a pin on the map myself" option above. Otherwise, the map will - center on your GPS location (if available) or your IP address location, depending - on your selection above. -

    - -
    - -

    - Map tiles provided by OpenStreetMap. -

    -
    -
    -
    -
    - - -
    -
    - - {{InputCSRF}} - -
    -
    -

    - - Account Settings -

    -
    - -
    -
    - -

    - Enter your current password before making any changes to your - email address or setting a new password. + Check this box if you are OK seeing explicit content on this site, which may + include erections or sexually charged content. These may appear on the Site + Gallery as well as user profile pages.

    -
    - - -
    - -
    - - - -
    -
    +
    - +
    - -
    -
    -

    - - Delete Account + +

    +
    + + {{InputCSRF}} + +
    + + +
    +

    + The settings on this page control your location for the Who's Nearby + feature of {{PrettyTitle}}. Being discoverable by your location is an opt-in + feature and you have your choice of options how you want your location to be found. +

    + +
    + + Notice: + Remember to click "Save" after setting your location preference! +
    + +
    + + +

    + This option will opt-out of the Who's Nearby feature and your profile will not be + discoverable by distance to other members. Any location data already stored by + the website will be erased if you choose this option. +

    + + +

    + Coarse location data based on your IP address. Might be accurate to your + city level. + + + {{if not .GeoIPInsights.IsZero}} +
    + Currently: + {{.GeoIPInsights}} + {{end}} +

    + + +

    + Your web browser will prompt you to share your current location with {{PrettyTitle}}. +
    + Currently: + You have not granted permission. + +

    + + +

    + Use the map below and drop a pin anywhere you like to set your location. +

    +
    + +
    + +

    + Your location will be saved as the following in the database: +

    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + +
    + +

    Map

    + +

    + This map shows your current location pin. To click and drop a pin manually, + select the "Drop a pin on the map myself" option above. Otherwise, the map will + center on your GPS location (if available) or your IP address location, depending + on your selection above. +

    + +
    + +

    + Map tiles provided by OpenStreetMap. +

    +
    +
    +
    +
    + + +
    +
    +

    + + Privacy Settings

    -

    - If you would like to delete your account, please click - on the button below. -

    +
    + {{InputCSRF}} + -

    - - Delete My Account - -

    +
    + + +

    + 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. +

    + + +

    + 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. + Preview +

    + + +

    + 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. +

    +
    + +
    + +
    + + +
    + + Note: this refers to Direct Messages on the main website + (not inside the chat room). + + {{.CurrentUser.GetProfileField "dm_privacy"}} +
    + + +

    + Almost any member of the site may send you a Direct Message from your profile + page (except for maybe Shy Accounts). +

    + + +

    + Nobody can slide into your DMs except for friends (and admins if needed). Anybody + may reply to messages that you send to them. +

    + + +

    + Nobody can start a Direct Message conversation with you on the main website + (except an admin if necessary). Anybody may reply to messages that you + sent to them first. +

    +
    + +
    + +
    + +
    + +
    +
    +
    + + +
    +
    + + +
    +

    + 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). +

    + +

    + {{PrettyTitle}} offers Two-Factor Authentication using the industry + standard "Time-based One-Time Password" (TOTP) system that is compatible + with Google Authenticator and Authy. +

    + +

    + Your Two-Factor is currently: + {{if .TwoFactorEnabled}} + + Enabled + {{else}} + + Not Enabled + {{end}} +

    + +

    + + Manage Two-Factor Authentication + +

    +
    +
    + +
    + + +
    +
    + + {{InputCSRF}} + +
    + + +

    + Enter your current password before making any changes to your + email address or setting a new password. +

    +
    +
    + + +
    + +
    + + + +
    + +
    + +
    +
    +
    +
    + + +
    +
    +

    + + Delete Account +

    +
    + +
    +

    + If you would like to delete your account, please click + on the button below. +

    + +

    + + Delete My Account + +

    +
    -
    {{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"); } }); diff --git a/web/templates/account/two_factor_login.html b/web/templates/account/two_factor_login.html new file mode 100644 index 0000000..d71231b --- /dev/null +++ b/web/templates/account/two_factor_login.html @@ -0,0 +1,62 @@ +{{define "title"}}Two-Factor Authentication{{end}} +{{define "content"}} +
    +
    +
    +
    +

    + + Two-Factor Authentication +

    +
    +
    +
    + + {{ $User := .CurrentUser }} + +
    +
    +
    +
    + + +
    +
    + {{InputCSRF}} + + + + +

    + 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. +

    + +

    + +

    + +

    + +

    +
    +
    +
    +
    +
    +
    +
    +{{end}} diff --git a/web/templates/account/two_factor_setup.html b/web/templates/account/two_factor_setup.html new file mode 100644 index 0000000..93d3170 --- /dev/null +++ b/web/templates/account/two_factor_setup.html @@ -0,0 +1,231 @@ +{{define "title"}}Two-Factor Authentication{{end}} +{{define "content"}} +
    +
    +
    +
    +

    + + Two-Factor Authentication +

    +
    +
    +
    + + {{ $User := .CurrentUser }} + +
    + + {{if .TwoFactor.Enabled}} +
    + + +
    +
    +

    + Two-Factor status: + + Enabled! +

    + +

    + 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. +

    + +

    Backup Codes

    + +

    + In case you lose access to your Authenticator App, please print off or write down these + backup codes which will allow you to re-gain access to your {{PrettyTitle}} + account. Each of these codes may be used one time in response to your + 2FA Authenticator prompt at login. +

    + +
    + {{range .TwoFactor.GetBackupCodes}} +
    + {{.}} +
    + {{end}} +
    + +

    + If you would like to re-generate 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! +

    + + +
    + {{InputCSRF}} + + +
    + +

    Disable Two-Factor Auth

    + +

    + If you wish to disable two-factor authentication for your account, please enter + your account password for verification and click on the button below. +

    + +
    + {{InputCSRF}} + + +
    + + +
    + + +
    +
    +
    +
    + {{end}} + + + {{if not .TwoFactor.Enabled}} +
    + + +
    +
    +

    + Two-Factor status: + + Disabled! +

    + +

    Set up your Authenticator App

    + +

    + 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: +

    + +
      +
    • + Google Authenticator: + for + Android + or + iOS + +
    • + +
    • + + Authy: + + available for Android and iOS + as well as Windows, macOS and Linux computers. +
    • +
    + +

    Add {{PrettyTitle}} to your Authenticator App

    + +

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

    + + {{ToHTML .QRCode}} + +

    + Alternatively (if you can't scan the QR code), you may copy and paste this secret text + in to your Authenticator app: +

    + +
    +
    + +
    +
    + +
    +
    + +

    Test your Authenticator App

    + +

    + 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. +

    + +

    + Test that you have enrolled your authenticator correctly by entering the current six-digit + code below: +

    + +
    + {{InputCSRF}} + +
    + + +
    + + +
    +
    +
    +
    +
    + {{end}} + +
    +{{end}} +{{define "scripts"}} + +{{end}}