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 }