Files
photoprism/internal/entity/passcode.go
2025-03-18 10:00:49 +01:00

350 lines
7.4 KiB
Go

package entity
import (
"bytes"
"errors"
"fmt"
"image"
"image/png"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Passcode represents a two-factor authentication key.
type Passcode struct {
UID string `gorm:"type:VARBINARY(255);primary_key;" json:"UID"`
KeyType string `gorm:"size:64;default:'';primary_key;" json:"Type" yaml:"Type"`
KeyURL string `gorm:"size:2048;default:'';column:key_url;" json:"-" yaml:"-"`
key *otp.Key `gorm:"-" yaml:"-"`
RecoveryCode string `gorm:"size:255;default:'';" json:"-" yaml:"-"`
VerifiedAt *time.Time `json:"VerifiedAt" yaml:"-"`
ActivatedAt *time.Time `json:"ActivatedAt" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
}
// TableName returns the entity table name.
func (Passcode) TableName() string {
return "passcodes"
}
// NewPasscode returns a new two-factor authentication key or nil if no valid entity UID was provided.
func NewPasscode(uid string, keyUrl, recoveryCode string) (*Passcode, error) {
// Create new authentication key.
m := &Passcode{
UID: uid,
KeyURL: keyUrl,
RecoveryCode: clean.Token(recoveryCode),
CreatedAt: Now(),
UpdatedAt: Now(),
VerifiedAt: nil,
ActivatedAt: nil,
}
// Return an error if the uid or key are invalid.
if rnd.InvalidUID(uid, 0) {
return m, errors.New("auth: invalid uid")
} else if keyUrl == "" {
return m, errors.New("auth: invalid url")
} else if err := m.SetKeyURL(keyUrl); err != nil {
return m, err
}
return m, nil
}
// FindPasscode returns the matching key or nil if it was not found.
func FindPasscode(find Passcode) *Passcode {
m := &Passcode{}
keyType := authn.Key(find.KeyType)
// Build query.
stmt := UnscopedDb()
if rnd.IsUID(find.UID, 0) {
stmt = stmt.Where("uid = ? AND key_type = ?", find.UID, keyType.String())
} else {
return nil
}
// Find matching record.
if err := stmt.First(m).Error; err != nil {
return nil
}
return m
}
// Create new entity in the database.
func (m *Passcode) Create() (err error) {
return Db().Create(m).Error
}
// Save updates the record in the database or inserts a new record if it does not exist yet.
func (m *Passcode) Save() (err error) {
return UnscopedDb().Save(m).Error
}
// Delete deletes the entity record.
func (m *Passcode) Delete() (err error) {
if m == nil {
return fmt.Errorf("entity is nil")
} else if m.UID == "" {
return fmt.Errorf("uid not set")
}
err = UnscopedDb().Delete(m).Error
return err
}
// Updates multiple properties in the database.
func (m *Passcode) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
}
// SetUID assigns a valid entity UID.
func (m *Passcode) SetUID(uid string) *Passcode {
if rnd.IsUID(uid, 0) {
m.UID = uid
}
return m
}
// InvalidUID checks if the entity UID is invalid.
func (m *Passcode) InvalidUID() bool {
if m == nil {
return true
}
return !rnd.IsUID(m.UID, 0)
}
// Key returns the parsed two-factor authentication key or nil if the KeyURL is invalid.
func (m *Passcode) Key() *otp.Key {
if m == nil {
return nil
} else if m.key != nil {
return m.key
}
key, err := otp.NewKeyFromURL(m.KeyURL)
if err != nil {
return nil
}
m.key = key
return m.key
}
// SetKey sets a new two-factor authentication key.
func (m *Passcode) SetKey(key *otp.Key) error {
if key == nil {
return errors.New("auth: key is nil")
}
if keyType := key.Type(); authn.KeyTOTP.NotEqual(keyType) {
return fmt.Errorf("auth: invalid key type %s", clean.Log(keyType))
} else if key.Secret() == "" {
return errors.New("auth: invalid key secret")
}
m.KeyType = key.Type()
m.KeyURL = key.URL()
m.key = key
return nil
}
// SetKeyURL sets a new two-factor authentication key based on the URL provided.
func (m *Passcode) SetKeyURL(keyUrl string) error {
key, err := otp.NewKeyFromURL(keyUrl)
if err != nil {
return fmt.Errorf("auth: %s", err)
} else if key == nil {
return errors.New("auth: failed to parse url")
}
return m.SetKey(key)
}
// Secret returns the key secret or an empty string if none is set.
func (m *Passcode) Secret() string {
if m == nil {
return ""
}
key := m.Key()
if key == nil {
return ""
}
return key.Secret()
}
// Type returns the normalized key type.
func (m *Passcode) Type() authn.KeyType {
if m == nil {
return ""
}
return authn.Key(m.KeyType)
}
// GenerateCode returns a valid passcode for testing.
func (m *Passcode) GenerateCode() (code string, err error) {
if m == nil {
return "", errors.New("passcode is nil")
}
// Get authentication key.
key := m.Key()
if key == nil {
return "", authn.ErrInvalidPasscodeKey
}
// Generate code depending on key type.
switch m.Type() {
case authn.KeyTOTP:
code, err = totp.GenerateCodeCustom(
key.Secret(),
time.Now().UTC(),
totp.ValidateOpts{
Period: uint(key.Period()),
Skew: 1,
Digits: key.Digits(),
Algorithm: key.Algorithm(),
},
)
default:
return "", authn.ErrInvalidPasscodeType
}
// Return result.
return code, err
}
// Valid checks if the passcode provided is valid.
func (m *Passcode) Valid(code string) (valid bool, recovery bool, err error) {
// Validate arguments.
if m == nil {
return false, false, errors.New("passcode is nil")
} else if code == "" {
return false, false, authn.ErrPasscodeRequired
} else if len(code) > 255 {
return false, false, authn.ErrInvalidPasscodeFormat
}
// Get authentication key.
key := m.Key()
if key == nil {
return false, false, authn.ErrInvalidPasscodeKey
}
// Check if recovery code has been used.
if m.RecoveryCode == code {
return true, true, nil
}
// Verify passcode.
switch m.Type() {
case authn.KeyTOTP:
valid, err = totp.ValidateCustom(
code,
key.Secret(),
time.Now().UTC(),
totp.ValidateOpts{
Period: uint(key.Period()),
Skew: 1,
Digits: key.Digits(),
Algorithm: key.Algorithm(),
},
)
default:
return false, false, authn.ErrInvalidPasscodeType
}
// Check if an error has been returned.
if err != nil {
return valid, false, err
}
// Set verified timestamp if nil.
if valid && m.VerifiedAt == nil {
m.VerifiedAt = TimeStamp()
err = m.Updates(Map{"VerifiedAt": m.VerifiedAt})
}
// Return result.
return valid, false, err
}
// Activate activates the passcode.
func (m *Passcode) Activate() (err error) {
if m == nil {
return errors.New("passcode is nil")
}
if m.VerifiedAt == nil {
return authn.ErrPasscodeNotVerified
} else if m.ActivatedAt != nil {
return authn.ErrPasscodeAlreadyActivated
} else {
m.ActivatedAt = TimeStamp()
err = m.Updates(Map{"ActivatedAt": m.ActivatedAt})
}
return err
}
// Image returns an image with a QR Code that can be used to initialize compatible authenticator apps.
func (m *Passcode) Image(size int) (image.Image, error) {
if m == nil {
return nil, errors.New("key is nil")
}
key := m.Key()
if key == nil {
return nil, authn.ErrPasscodeNotSetUp
}
return key.Image(size, size)
}
// Png returns a PNG image buffer with a QR Code that can be used to initialize compatible authenticator apps.
func (m *Passcode) Png(size int) *bytes.Buffer {
if m == nil {
return nil
}
img, err := m.Image(size)
if err != nil {
return nil
}
var buf bytes.Buffer
err = png.Encode(&buf, img)
if err != nil {
return nil
}
return &buf
}