Two Factor Authentication

This commit is contained in:
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 ( require (
github.com/aymerick/douceur v0.2.0 // indirect 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/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/microcosm-cc/bluemonday v1.0.19 // indirect
github.com/oschwald/geoip2-golang v1.9.0 // indirect github.com/oschwald/geoip2-golang v1.9.0 // indirect
github.com/oschwald/maxminddb-golang v1.11.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 v1.5.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // 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/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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 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 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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= 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/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/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/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/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/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

View File

@ -35,6 +35,9 @@ const (
SessionRedisKeyFormat = "session/%s" SessionRedisKeyFormat = "session/%s"
MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs) MaxBodySize = 1024 * 1024 * 8 // max upload file (e.g., 8 MB gifs)
MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB MultipartMaxMemory = 1024 * 1024 * 1024 * 20 // 20 MB
TwoFactorBackupCodeCount = 12
TwoFactorBackupCodeLength = 8 // characters a-z0-9
) )
// Authentication // Authentication

View File

@ -7,13 +7,14 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"github.com/google/uuid" "github.com/google/uuid"
) )
// Version of the config format - when new fields are added, it will attempt // Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate. // to write the settings.toml to disk so new defaults populate.
var currentVersion = 1 var currentVersion = 2
// Current loaded settings.json // Current loaded settings.json
var Current = DefaultVariable() var Current = DefaultVariable()
@ -29,6 +30,7 @@ type Variable struct {
Database Database Database Database
BareRTC BareRTC BareRTC BareRTC
Maintenance Maintenance Maintenance Maintenance
Encryption Encryption
UseXForwardedFor bool 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. // LoadSettings loads the settings.json file or, if not existing, creates it with the default settings.
func LoadSettings() { func LoadSettings() {
var writeSettings bool
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) { if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
log.Info("Loading settings from %s", SettingsPath) log.Info("Loading settings from %s", SettingsPath)
content, err := ioutil.ReadFile(SettingsPath) content, err := ioutil.ReadFile(SettingsPath)
@ -81,8 +85,20 @@ func LoadSettings() {
os.Exit(1) 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. // 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.") log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
Current.Version = currentVersion Current.Version = currentVersion
if err := WriteSettings(); err != nil { if err := WriteSettings(); err != nil {
@ -144,3 +160,8 @@ type Maintenance struct {
PauseChat bool PauseChat bool
PauseInteraction bool PauseInteraction bool
} }
// Encryption settings.
type Encryption struct {
AESKey []byte
}

View File

@ -96,6 +96,9 @@ func Dashboard() http.HandlerFunc {
// Who's Nearby stats. // Who's Nearby stats.
"MyLocation": myLocation, "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 { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -17,6 +17,7 @@ import (
// Login controller. // Login controller.
func Login() http.HandlerFunc { func Login() http.HandlerFunc {
tmpl := templates.Must("account/login.html") tmpl := templates.Must("account/login.html")
tmpl2fa := templates.Must("account/two_factor_login.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var next = r.FormValue("next") var next = r.FormValue("next")
@ -75,6 +76,46 @@ func Login() http.HandlerFunc {
return 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. // OK. Log in the user's session.
session.LoginUser(w, r, user) session.LoginUser(w, r, user)

View File

@ -122,11 +122,23 @@ func Settings() http.HandlerFunc {
case "preferences": case "preferences":
hashtag = "#prefs" hashtag = "#prefs"
var ( var (
explicit = r.PostFormValue("explicit") == "true" explicit = r.PostFormValue("explicit") == "true"
visibility = models.UserVisibility(r.PostFormValue("visibility"))
) )
user.Explicit = explicit 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 user.Visibility = models.UserVisibilityPublic
for _, cmp := range models.UserVisibilityOptions { for _, cmp := range models.UserVisibilityOptions {
@ -136,13 +148,13 @@ func Settings() http.HandlerFunc {
} }
// Set profile field prefs. // Set profile field prefs.
user.SetProfileField("dm_privacy", r.PostFormValue("dm_privacy")) user.SetProfileField("dm_privacy", dmPrivacy)
if err := user.Save(); err != nil { if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err) 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": case "location":
hashtag = "#location" hashtag = "#location"
var ( var (
@ -269,6 +281,9 @@ func Settings() http.HandlerFunc {
vars["GeoIPInsights"] = insights vars["GeoIPInsights"] = insights
vars["UserLocation"] = models.GetUserLocation(user.ID) 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 { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return 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(&AdminScope{})
DB.AutoMigrate(&UserLocation{}) DB.AutoMigrate(&UserLocation{})
DB.AutoMigrate(&UserNote{}) 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("/me", middleware.LoginRequired(account.Dashboard()))
mux.Handle("/settings", middleware.LoginRequired(account.Settings())) mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
mux.Handle("/settings/age-gate", middleware.LoginRequired(account.AgeGate())) 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("/account/delete", middleware.LoginRequired(account.Delete()))
mux.Handle("/u/", account.Profile()) // public access OK mux.Handle("/u/", account.Profile()) // public access OK
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload())) mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))

View File

@ -32,6 +32,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"Split": strings.Split, "Split": strings.Split,
"ToMarkdown": ToMarkdown, "ToMarkdown": ToMarkdown,
"ToJSON": ToJSON, "ToJSON": ToJSON,
"ToHTML": ToHTML,
"PhotoURL": photo.URLPath, "PhotoURL": photo.URLPath,
"Now": time.Now, "Now": time.Now,
"PrettyTitle": func() template.HTML { "PrettyTitle": func() template.HTML {
@ -93,6 +94,11 @@ func ToMarkdown(input string) template.HTML {
return template.HTML(markdown.Render(input)) 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. // ToJSON will stringify any json-serializable object.
func ToJSON(v any) template.JS { func ToJSON(v any) template.JS {
bin, err := json.Marshal(v) bin, err := json.Marshal(v)

View File

@ -133,40 +133,32 @@
</div> </div>
{{end}} {{end}}
<!-- New Feature: Who's Nearby --> <!-- New Feature: Two Factor Auth -->
{{if not .MyLocation.Source}} {{if not .TwoFactorEnabled}}
<div class="card block"> <div class="card block">
<header class="card-header has-background-success-dark"> <header class="card-header has-background-success-dark">
<p class="card-header-title has-text-light"> <p class="card-header-title has-text-light">
<i class="fa fa-gift mr-2"></i> <i class="fa fa-gift mr-2"></i>
New Feature: Who's Nearby? New Feature: Two-Factor Authentication
</p> </p>
</header> </header>
<div class="card-content"> <div class="card-content">
<p class="block"> <p class="block">
We've recently added a new feature! <strong>Who's Nearby</strong> can allow you to sort the We've recently added a new security feature you can use to better protect your account:
<a href="/members">Member Directory</a> by their distance away from you. <strong>Two-Factor Authentication (2FA)!</strong>
</p> </p>
<p class="block"> <p class="block">
First, you'll need to opt-in where <em>your</em> location is so that the site can know who's This feature can help protect your account in case somebody finds out your password. When
nearby. Please visit your <a href="/settings#location">Location Settings</a> page to choose enabled, you can use an authenticator app (such as Google Authenticator or Authy) to generate
how you share your location -- you can even just drop a pin on a map anywhere you're comfortable a one-time use passcode to log in to your account.
with!
</p> </p>
<p class="block"> <p class="block">
Then, you'll be able to <a href="/members?sort=distance">sort the Member Directory by distance</a>. If interested, see your <a href="/account/two-factor/setup">Two Factor Auth</a> settings
Only people whose location is known will show in the results. page to get started! This message will disappear after you have enrolled in 2FA, or after
</p> a few weeks when enough people have had a chance to learn about the new feature.
<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!
</p> </p>
</div> </div>
</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}}