photoprism/internal/entity/auth_user.go
Michael Mayer 85561547cc Auth: Add "PHOTOPRISM_ADMIN_USER" option and refactor user table #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
2022-09-02 21:30:50 +02:00

501 lines
15 KiB
Go

package entity
import (
"errors"
"fmt"
"net/mail"
"time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
var UsernameLength = 3
var PasswordLength = 4
// Users represents a list of users.
type Users []User
// User represents a person that may optionally log in as user.
type User struct {
ID int `gorm:"primary_key" json:"-" yaml:"-"`
UserUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
UserSlug string `gorm:"type:VARBINARY(160);index;" json:"Slug" yaml:"Slug,omitempty"`
Username string `gorm:"size:64;index;" json:"Username" yaml:"Username,omitempty"`
Email string `gorm:"size:255;index;" json:"Email" yaml:"Email,omitempty"`
UserRole string `gorm:"size:32;" json:"Role" yaml:"Role,omitempty"`
SuperAdmin bool `gorm:"default:false;" json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
CanLogin bool `gorm:"default:false;" json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
CanInvite bool `gorm:"default:false;" json:"CanInvite,omitempty" yaml:"CanInvite,omitempty"`
ShareUID string `gorm:"type:VARBINARY(42);index;" json:"ShareUID" yaml:"ShareUID,omitempty"`
AuthUID string `gorm:"type:VARBINARY(512);column:auth_uid;" json:"-" yaml:"-"`
AuthSrc string `gorm:"type:VARBINARY(64);column:auth_src;" json:"-" yaml:"-"`
WebDAV string `gorm:"size:16;column:webdav;" json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
AvatarURL string `gorm:"type:VARBINARY(255);column:avatar_url;" json:"AvatarURL" yaml:"AvatarURL,omitempty"`
AvatarSrc string `gorm:"type:VARBINARY(64);column:avatar_src;" json:"AvatarSrc" yaml:"AvatarSrc,omitempty"`
UserCountry string `gorm:"type:VARBINARY(2);" json:"Country" yaml:"Country,omitempty"`
UserLocale string `gorm:"type:VARBINARY(64);" json:"Locale" yaml:"Locale,omitempty"`
TimeZone string `gorm:"type:VARBINARY(64);default:'';" json:"TimeZone" yaml:"TimeZone,omitempty"`
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID,omitempty" yaml:"-"`
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc,omitempty" yaml:"PlaceSrc,omitempty"`
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"CellID"`
SubjUID string `gorm:"type:VARBINARY(42);index;" json:"SubjUID" yaml:"SubjUID,omitempty"`
UserBio string `gorm:"size:255;" json:"Bio,omitempty" yaml:"Bio,omitempty"`
UserStatus string `gorm:"size:32;" json:"Status,omitempty" yaml:"Status,omitempty"`
UserURL string `gorm:"size:255;column:user_url" json:"URL,omitempty" yaml:"URL,omitempty"`
UserPhone string `gorm:"size:32;" json:"Phone,omitempty" yaml:"Phone,omitempty"`
FullName string `gorm:"size:128;" json:"FullName" yaml:"FullName,omitempty"`
DisplayName string `gorm:"size:64;" json:"DisplayName" yaml:"DisplayName,omitempty"`
UserAlias string `gorm:"size:64;" json:"Alias" yaml:"Alias,omitempty"`
ArtistName string `gorm:"size:64;" json:"ArtistName,omitempty" yaml:"ArtistName,omitempty"`
UserArtist bool `gorm:"default:false;" json:"Artist,omitempty" yaml:"Artist,omitempty"`
UserFavorite bool `gorm:"default:false;" json:"Favorite" yaml:"Favorite,omitempty"`
UserHidden bool `gorm:"default:false;" json:"Hidden" yaml:"Hidden,omitempty"`
UserPrivate bool `gorm:"default:false;" json:"Private" yaml:"Private,omitempty"`
UserExcluded bool `gorm:"default:false;" json:"Excluded" yaml:"Excluded,omitempty"`
CompanyName string `gorm:"size:128;" json:"CompanyName,omitempty" yaml:"CompanyName,omitempty"`
DepartmentName string `gorm:"size:128;" json:"DepartmentName,omitempty" yaml:"DepartmentName,omitempty"`
JobTitle string `gorm:"size:64;" json:"JobTitle,omitempty" yaml:"JobTitle,omitempty"`
BusinessURL string `gorm:"size:255" json:"BusinessURL,omitempty" yaml:"BusinessURL,omitempty"`
BusinessPhone string `gorm:"size:32;" json:"BusinessPhone,omitempty" yaml:"BusinessPhone,omitempty"`
BusinessEmail string `gorm:"size:255;" json:"BusinessEmail,omitempty" yaml:"BusinessEmail,omitempty"`
BackupEmail string `gorm:"size:255;" json:"BackupEmail,omitempty" yaml:"BackupEmail,omitempty"`
BirthYear int `gorm:"default:-1;" json:"BirthYear" yaml:"BirthYear,omitempty"`
BirthMonth int `gorm:"default:-1;" json:"BirthMonth" yaml:"BirthMonth,omitempty"`
BirthDay int `gorm:"default:-1;" json:"BirthDay" yaml:"BirthDay,omitempty"`
FileRoot string `gorm:"type:VARBINARY(16);column:file_root;" json:"FileRoot,omitempty" yaml:"FileRoot,omitempty"`
FilePath string `gorm:"type:VARBINARY(500);column:file_path;" json:"FilePath,omitempty" yaml:"FilePath,omitempty"`
InviteToken string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
InvitedBy string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
DownloadToken string `gorm:"column:download_token;type:VARBINARY(128);" json:"-" yaml:"-"`
PreviewToken string `gorm:"column:preview_token;type:VARBINARY(128);" json:"-" yaml:"-"`
ResetToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
ConfirmToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
ConfirmedAt *time.Time `json:"ConfirmedAt,omitempty" yaml:"ConfirmedAt,omitempty"`
TermsAccepted *time.Time `json:"TermsAccepted,omitempty" yaml:"TermsAccepted,omitempty"`
LoginAttempts int `json:"-" yaml:"-"`
LoginAt *time.Time `json:"-" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
}
// TableName returns the entity database table name.
func (User) TableName() string {
return "auth_users"
}
// InitAccount sets the name and password of the initial admin account.
func (m *User) InitAccount(login, password string) (updated bool) {
if !m.IsRegistered() {
log.Warn("only registered users can change their password")
return false
}
// Password must not be empty.
if password == "" {
return false
}
// Update username as well if needed.
if err := m.UpdateName(login); err != nil {
log.Errorf("user: %s", err.Error())
return false
}
existing := FindPassword(m.UserUID)
if existing != nil {
return false
}
pw := NewPassword(m.UserUID, password)
// Update password in database.
if err := pw.Save(); err != nil {
log.Error(err)
return false
}
return true
}
// Create new entity in the database.
func (m *User) Create() error {
return Db().Create(m).Error
}
// Save entity properties.
func (m *User) Save() error {
return Db().Save(m).Error
}
// Updates multiple properties in the database.
func (m *User) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *User) BeforeCreate(scope *gorm.Scope) error {
m.UserSlug = m.GenerateSlug()
if err := scope.SetColumn("UserSlug", m.UserSlug); err != nil {
return err
}
if rnd.ValidID(m.UserUID, 'u') {
return nil
}
m.UserUID = rnd.GenerateUID('u')
return scope.SetColumn("UserUID", m.UserUID)
}
// GenerateSlug returns an updated slug.
func (m *User) GenerateSlug() string {
if l := clean.Login(m.Username); l != "" {
return txt.Slug(l)
} else if m.UserSlug == "" {
return rnd.GenerateToken(8)
}
return m.UserSlug
}
// FirstOrCreateUser returns an existing row, inserts a new row, or nil in case of errors.
func FirstOrCreateUser(m *User) *User {
result := User{}
m.UserSlug = m.GenerateSlug()
if err := Db().Where("id = ? OR user_uid = ?", m.ID, m.UserUID).First(&result).Error; err == nil {
return &result
} else if err := m.Create(); err != nil {
log.Debugf("user: %s", err)
return nil
}
return m
}
// FindUserByLogin returns an existing user or nil if not found.
func FindUserByLogin(s string) *User {
if s == "" {
return nil
}
result := User{}
// Find by Login.
if name := clean.Login(s); name == "" {
return nil
} else if err := Db().Where("username = ?", name).First(&result).Error; err == nil {
return &result
} else {
log.Debugf("username %s not found", clean.LogQuote(name))
}
// Find by Email.
if email := clean.Email(s); email == "" {
return nil
} else if err := Db().Where("email = ?", email).First(&result).Error; err == nil {
return &result
} else {
log.Debugf("email %s not found", clean.LogQuote(email))
}
return nil
}
// FindUserByUID returns an existing user or nil if not found.
func FindUserByUID(uid string) *User {
if uid == "" {
return nil
}
result := User{}
if err := Db().Where("user_uid = ?", uid).First(&result).Error; err == nil {
return &result
} else {
log.Debugf("user uid %s not found", clean.LogQuote(uid))
return nil
}
}
// Delete marks the entity as deleted.
func (m *User) Delete() error {
if m.ID <= 1 {
return fmt.Errorf("cannot delete system user")
}
return Db().Delete(m).Error
}
// Deleted tests if the entity is marked as deleted.
func (m *User) Deleted() bool {
if m.DeletedAt == nil {
return false
}
return !m.DeletedAt.IsZero()
}
// String returns an identifier that can be used in logs.
func (m *User) String() string {
if n := m.UserName(); n != "" {
return clean.Log(n)
} else if n = m.RealName(); n != "" {
return clean.Log(n)
} else if m.UserSlug != "" {
return clean.Log(m.UserSlug)
}
return clean.Log(m.UserUID)
}
// UserName returns the login username.
func (m *User) UserName() string {
return clean.Login(m.Username)
}
// UserEmail returns the login email address.
func (m *User) UserEmail() string {
switch {
case m.Email != "":
return m.Email
case m.BackupEmail != "":
return m.BackupEmail
case m.BusinessEmail != "":
return m.BusinessEmail
default:
return ""
}
}
// RealName returns the user' real name if known.
func (m *User) RealName() string {
switch {
case m.FullName != "":
return m.FullName
case m.DisplayName != "":
return m.DisplayName
case m.ArtistName != "":
return m.ArtistName
default:
return ""
}
}
// SetUsername sets the login username to the specified string.
func (m *User) SetUsername(login string) (err error) {
login = clean.Login(login)
// Empty?
if login == "" {
return fmt.Errorf("new username is empty")
}
// Update username and slug.
m.Username = login
m.UserSlug = m.GenerateSlug()
// Update display name.
if m.DisplayName == "" || m.DisplayName == DefaultAdminFullName {
m.DisplayName = clean.Name(login)
}
return nil
}
// UpdateName changes the login username and saves it to the database.
func (m *User) UpdateName(login string) (err error) {
if err = m.SetUsername(login); err != nil {
return err
}
// Save to database.
return m.Updates(Values{
"UserSlug": m.UserSlug,
"Username": m.Username,
"DisplayName": m.DisplayName,
})
}
// AclRole returns the user role for ACL permission checks.
func (m *User) AclRole() acl.Role {
role := clean.Role(m.UserRole)
switch {
case m.SuperAdmin:
return acl.RoleAdmin
case role == "":
return acl.RoleDefault
case acl.RoleAdmin.Equal(role):
return acl.RoleAdmin
case acl.RoleEditor.Equal(role):
return acl.RoleEditor
case acl.RoleViewer.Equal(role):
return acl.RoleViewer
case acl.RoleGuest.Equal(role):
return acl.RoleGuest
default:
return acl.Role(role)
}
}
// IsRegistered checks if the user is registered e.g. has a username.
func (m *User) IsRegistered() bool {
return m.UserName() != "" && rnd.EntityUID(m.UserUID, 'u')
}
// IsAdmin checks if the user is an admin with username.
func (m *User) IsAdmin() bool {
return m.IsRegistered() && m.AclRole() == acl.RoleAdmin
}
// IsEditor checks if the user is an editor with username.
func (m *User) IsEditor() bool {
return m.IsRegistered() && m.AclRole() == acl.RoleEditor
}
// IsViewer checks if the user is a viewer with username.
func (m *User) IsViewer() bool {
return m.IsRegistered() && m.AclRole() == acl.RoleViewer
}
// IsGuest checks if the user is a guest.
func (m *User) IsGuest() bool {
return m.AclRole() == acl.RoleGuest
}
// IsAnonymous checks if the user is unknown.
func (m *User) IsAnonymous() bool {
return !rnd.EntityUID(m.UserUID, 'u') || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID
}
// SetPassword sets a new password stored as hash.
func (m *User) SetPassword(password string) error {
if !m.IsRegistered() {
return fmt.Errorf("only registered users can change their password")
}
if len(password) < PasswordLength {
return fmt.Errorf("password must have at least %d characters", PasswordLength)
}
pw := NewPassword(m.UserUID, password)
return pw.Save()
}
// InvalidPassword returns true if the given password does not match the hash.
func (m *User) InvalidPassword(password string) bool {
if !m.IsRegistered() {
log.Warn("only registered users can change their password")
return true
}
if password == "" {
return true
}
time.Sleep(time.Second * 5 * time.Duration(m.LoginAttempts))
pw := FindPassword(m.UserUID)
if pw == nil {
return true
}
if pw.InvalidPassword(password) {
if err := Db().Model(m).UpdateColumn("login_attempts", gorm.Expr("login_attempts + ?", 1)).Error; err != nil {
log.Errorf("user: %s (update login attempts)", err)
}
return true
}
if err := Db().Model(m).Updates(map[string]interface{}{"login_attempts": 0, "login_at": TimeStamp()}).Error; err != nil {
log.Errorf("user: %s (update last login)", err)
}
return false
}
// Validate Makes sure username and email are unique and meet requirements. Returns error if any property is invalid
func (m *User) Validate() error {
if m.UserName() == "" {
return errors.New("username must not be empty")
}
if len(m.UserName()) < UsernameLength {
return fmt.Errorf("username must have at least %d characters", UsernameLength)
}
var err error
var resultName = User{}
if err = Db().Where("username = ? AND id <> ?", m.Username, m.ID).First(&resultName).Error; err == nil {
return errors.New("username already exists")
} else if err != gorm.ErrRecordNotFound {
return err
}
// stop here if no email is provided
if m.Email == "" {
return nil
}
// validate email address.
if a, err := mail.ParseAddress(m.Email); err != nil {
return err
} else {
m.Email = a.Address // make sure email will be used without name.
}
var resultMail = User{}
if err = Db().Where("email = ? AND id <> ?", m.Email, m.ID).First(&resultMail).Error; err == nil {
return errors.New("email already exists")
} else if err != gorm.ErrRecordNotFound {
return err
}
return nil
}
// CreateWithPassword Creates User with Password in db transaction.
func CreateWithPassword(uc form.UserCreate) error {
u := &User{
Username: uc.Username,
Email: uc.Email,
SuperAdmin: true,
}
if len(uc.Password) < PasswordLength {
return fmt.Errorf("password must have at least %d characters", PasswordLength)
}
if err := u.Validate(); err != nil {
return err
}
return Db().Transaction(func(tx *gorm.DB) error {
if err := tx.Create(u).Error; err != nil {
return err
}
pw := NewPassword(u.UserUID, uc.Password)
if err := tx.Create(&pw).Error; err != nil {
return err
}
log.Infof("added user %s with uid %s", clean.Log(u.UserName()), clean.Log(u.UserUID))
return nil
})
}