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(``, 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.
- 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.
+ 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.
+
+ 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}}
+
+
+
+
+ Set Up Two-Factor Authentication
+
+
+
+
+
+
+ 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:
+
+
+ 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:
+