photoprism/internal/entity/auth_user.go
Michael Mayer 6e74f16a77 Auth: Open album share links in the regular user interface #98 #782
Signed-off-by: Michael Mayer <michael@photoprism.app>
2022-10-02 11:38:30 +02:00

706 lines
19 KiB
Go

package entity
import (
"errors"
"fmt"
"net/mail"
"path"
"strings"
"time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// User identifier prefixes.
const (
UserUID = byte('u')
UserPrefix = "user"
OwnerUnknown = ""
)
// LenNameMin specifies the minimum length of the username in characters.
var LenNameMin = 3
// LenPasswordMin specifies the minimum length of the password in characters.
var LenPasswordMin = 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:"-"`
UUID string `gorm:"type:VARBINARY(64);column:user_uuid;index;" json:"UUID,omitempty" yaml:"UUID,omitempty"`
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;unique_index;" json:"UID" yaml:"UID"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,omitempty"`
UserName string `gorm:"size:64;index;" json:"Name" yaml:"Name,omitempty"`
DisplayName string `gorm:"size:200;" json:"DisplayName" yaml:"DisplayName,omitempty"`
UserEmail string `gorm:"size:255;index;" json:"Email" yaml:"Email,omitempty"`
BackupEmail string `gorm:"size:255;" json:"BackupEmail,omitempty" yaml:"BackupEmail,omitempty"`
UserRole string `gorm:"size:64;default:'restricted';" json:"Role,omitempty" yaml:"Role,omitempty"`
UserAttr string `gorm:"size:1024;" json:"Attr,omitempty" yaml:"Attr,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
LoginAt *time.Time `json:"LoginAt,omitempty" yaml:"LoginAt,omitempty"`
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
CanSync bool `json:"CanSync,omitempty" yaml:"CanSync,omitempty"`
CanInvite bool `json:"CanInvite,omitempty" yaml:"CanInvite,omitempty"`
InviteToken string `gorm:"type:VARBINARY(64);index;" json:"-" yaml:"-"`
InvitedBy string `gorm:"size:64;" json:"-" yaml:"-"`
VerifyToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
VerifiedAt *time.Time `json:"VerifiedAt,omitempty" yaml:"VerifiedAt,omitempty"`
ConsentAt *time.Time `json:"ConsentAt,omitempty" yaml:"ConsentAt,omitempty"`
BornAt *time.Time `sql:"index" json:"BornAt,omitempty" yaml:"BornAt,omitempty"`
UserDetails *UserDetails `gorm:"PRELOAD:true;foreignkey:UserUID;association_foreignkey:UserUID;" json:"Details,omitempty" yaml:"Details,omitempty"`
UserSettings *UserSettings `gorm:"PRELOAD:true;foreignkey:UserUID;association_foreignkey:UserUID;" json:"Settings,omitempty" yaml:"Settings,omitempty"`
ResetToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
PreviewToken string `gorm:"type:VARBINARY(64);column:preview_token;" json:"-" yaml:"-"`
DownloadToken string `gorm:"type:VARBINARY(64);column:download_token;" json:"-" yaml:"-"`
Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb,omitempty" yaml:"Thumb,omitempty"`
ThumbSrc string `gorm:"type:VARBINARY(8);default:'';" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
RefID string `gorm:"type:VARBINARY(16);" json:"-" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
Shares Shares `gorm:"-" json:"Shares,omitempty" yaml:"Shares,omitempty"`
}
// TableName returns the entity table name.
func (User) TableName() string {
return "auth_users_dev"
}
// NewUser creates a new user and returns it.
func NewUser() (m *User) {
uid := rnd.GenerateUID(UserUID)
return &User{
UserUID: uid,
UserDetails: NewUserDetails(uid),
UserSettings: NewUserSettings(uid),
RefID: rnd.RefID(UserPrefix),
}
}
// FirstOrCreateUser returns an existing record, inserts a new record, or returns nil in case of an error.
func FirstOrCreateUser(m *User) *User {
result := User{}
if err := Db().Where("id = ? OR (user_uid = ? AND user_uid <> '') OR (user_name = ? AND user_name <> '')", m.ID, m.UserUID, m.UserName).First(&result).Error; err == nil {
return &result
} else if err = m.Create(); err != nil {
event.AuditErr([]string{"user", "failed to create", "%s"}, err)
return nil
}
return m
}
// FindUserByName finds a user by its username or returns nil if it was not found.
func FindUserByName(name string) *User {
name = clean.Username(name)
if name == "" {
return nil
}
m := &User{}
// Find matching record.
if Db().First(m, "user_name = ?", name).RecordNotFound() {
return nil
}
// Fetch related settings and details.
return m.LoadRelated()
}
// FindUserByUID returns an existing user or nil if not found.
func FindUserByUID(uid string) *User {
if rnd.InvalidUID(uid, UserUID) {
return nil
}
m := &User{}
// Find matching record.
if UnscopedDb().First(m, "user_uid = ?", uid).RecordNotFound() {
return nil
}
// Fetch related settings and details.
return m.LoadRelated()
}
// UID returns the unique id as string.
func (m *User) UID() string {
if m == nil {
return ""
}
return m.UserUID
}
// SameUID checks if the given uid matches the own uid.
func (m *User) SameUID(uid string) bool {
if m == nil {
return false
} else if m.UserUID == "" || rnd.InvalidUID(uid, UserUID) {
return false
}
return m.UserUID == uid
}
// 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
}
existing := FindPassword(m.UserUID)
if existing != nil {
return false
}
pw := NewPassword(m.UserUID, password)
// Save password.
if err := pw.Save(); err != nil {
event.AuditErr([]string{"user %s", "failed to change password", "%s"}, m.RefID, err)
return false
}
// Change username.
if err := m.UpdateName(login); err != nil {
event.AuditErr([]string{"user %s", "failed to change username to %s", "%s"}, m.RefID, clean.Log(login), err)
}
return true
}
// Create new entity in the database.
func (m *User) Create() (err error) {
err = Db().Create(m).Error
if err == nil {
m.SaveRelated()
}
return err
}
// Save updates the record in the database or inserts a new record if it does not already exist.
func (m *User) Save() (err error) {
err = Db().Save(m).Error
if err == nil {
m.SaveRelated()
}
return err
}
// 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 checks if the user account has been deleted.
func (m *User) Deleted() bool {
if m.DeletedAt == nil {
return false
}
return !m.DeletedAt.IsZero()
}
// LoadRelated loads related settings and details.
func (m *User) LoadRelated() *User {
m.Settings()
m.Details()
return m
}
// SaveRelated saves related settings and details.
func (m *User) SaveRelated() *User {
if err := m.Settings().Save(); err != nil {
event.AuditErr([]string{"user %s", "failed to save settings", "%s"}, m.RefID, err)
}
if err := m.Details().Save(); err != nil {
event.AuditErr([]string{"user %s", "failed to save details", "%s"}, m.RefID, err)
}
return m
}
// Updates multiple properties in the database.
func (m *User) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
}
// BeforeCreate sets a random UID if needed before inserting a new row to the database.
func (m *User) BeforeCreate(scope *gorm.Scope) error {
if m.UserSettings != nil {
m.UserSettings.UserUID = m.UserUID
}
if m.UserDetails != nil {
m.UserDetails.UserUID = m.UserUID
}
if rnd.InvalidRefID(m.RefID) {
m.RefID = rnd.RefID(UserPrefix)
Log("user", "set ref id", scope.SetColumn("RefID", m.RefID))
}
if rnd.IsUnique(m.UserUID, UserUID) {
return nil
}
m.UserUID = rnd.GenerateUID(UserUID)
return scope.SetColumn("UserUID", m.UserUID)
}
// Expired checks if the user account has expired.
func (m *User) Expired() bool {
if m.ExpiresAt == nil {
return false
}
return m.ExpiresAt.Before(time.Now())
}
// Disabled checks if the user account has been deleted or has expired.
func (m *User) Disabled() bool {
return m.Deleted() || m.Expired()
}
// LoginAllowed checks if the user is allowed to log in and use the web UI.
func (m *User) LoginAllowed() bool {
if role := m.AclRole(); m.Disabled() || !m.CanLogin || m.UserName == "" || role == acl.RoleUnauthorized {
return false
} else {
return acl.Resources.Allow(acl.ResourceConfig, role, acl.AccessOwn)
}
}
// SyncAllowed checks whether the user is allowed to use WebDAV to synchronize files.
func (m *User) SyncAllowed() bool {
if role := m.AclRole(); m.Disabled() || !m.CanSync || m.UserName == "" || role == acl.RoleUnauthorized {
return false
} else {
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
}
}
// UploadAllowed checks if the user is allowed to upload files.
func (m *User) UploadAllowed() bool {
if role := m.AclRole(); m.Disabled() || role == acl.RoleUnauthorized {
return false
} else {
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
}
}
// SetBasePath changes the user's base folder.
func (m *User) SetBasePath(dir string) *User {
m.BasePath = clean.UserPath(dir)
return m
}
// SetUploadPath changes the user's upload folder.
func (m *User) SetUploadPath(dir string) *User {
if m.BasePath == "" {
m.UploadPath = clean.UserPath(dir)
} else {
m.UploadPath = path.Join(m.BasePath, clean.UserPath(dir))
}
return m
}
// String returns an identifier that can be used in logs.
func (m *User) String() string {
if n := m.Name(); n != "" {
return clean.Log(n)
} else if n = m.FullName(); n != "" {
return clean.Log(n)
}
return clean.Log(m.UserUID)
}
// Name returns the user's login name for authentication.
func (m *User) Name() string {
return clean.Username(m.UserName)
}
// SetName sets the login username to the specified string.
func (m *User) SetName(login string) (err error) {
login = clean.Username(login)
// Empty?
if login == "" {
return fmt.Errorf("username cannot be empty")
}
// Update username and slug.
m.UserName = login
// Update display name.
if m.DisplayName == "" || m.DisplayName == AdminDisplayName {
m.DisplayName = clean.NameCapitalized(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.SetName(login); err != nil {
return err
}
// Save to database.
return m.Updates(Values{
"UserName": m.UserName,
"DisplayName": m.DisplayName,
})
}
// Email returns the user's login email for authentication.
func (m *User) Email() string {
return clean.Email(m.UserEmail)
}
// FullName returns the name of the user for display purposes.
func (m *User) FullName() string {
switch {
case m.DisplayName != "":
return m.DisplayName
default:
return m.UserName
}
}
// 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.RoleUnauthorized
case m.UserName == "":
return acl.RoleVisitor
default:
return acl.ValidRoles[role]
}
}
// Settings returns the user settings and initializes them if necessary.
func (m *User) Settings() *UserSettings {
if m.UserSettings != nil {
m.UserSettings.UserUID = m.UserUID
return m.UserSettings
} else if m.UID() == "" {
m.UserSettings = &UserSettings{}
return m.UserSettings
} else if err := CreateUserSettings(m); err != nil {
m.UserSettings = NewUserSettings(m.UserUID)
}
return m.UserSettings
}
// Details returns user profile information and initializes it if needed.
func (m *User) Details() *UserDetails {
if m.UserDetails != nil {
m.UserDetails.UserUID = m.UserUID
return m.UserDetails
} else if m.UID() == "" {
m.UserDetails = &UserDetails{}
return m.UserDetails
} else if err := CreateUserDetails(m); err != nil {
m.UserDetails = NewUserDetails(m.UserUID)
}
return m.UserDetails
}
// Attr returns optional user account attributes as sanitized string.
// Example: https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/useraccountcontrol-manipulate-account-properties
func (m *User) Attr() string {
return clean.Attr(m.UserAttr)
}
// IsRegistered checks if the user is registered e.g. has a username.
func (m *User) IsRegistered() bool {
if m == nil {
return false
}
return m.UserName != "" && rnd.IsUID(m.UserUID, UserUID) && !m.IsVisitor()
}
// NotRegistered checks if the user is not registered with an own account.
func (m *User) NotRegistered() bool {
return !m.IsRegistered()
}
// IsAdmin checks if the user is an admin with username.
func (m *User) IsAdmin() bool {
if m == nil {
return false
}
return m.IsRegistered() && (m.SuperAdmin || m.AclRole() == acl.RoleAdmin)
}
// IsVisitor checks if the user is a sharing link visitor.
func (m *User) IsVisitor() bool {
return m.AclRole() == acl.RoleVisitor || m.ID == Visitor.ID
}
// IsUnknown checks if the user is unknown.
func (m *User) IsUnknown() bool {
return !rnd.IsUID(m.UserUID, UserUID) || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID
}
// DeleteSessions deletes all active user sessions except those passed as argument.
func (m *User) DeleteSessions(omit []string) (deleted int) {
if m.UserUID == "" {
return 0
}
// Find all user sessions except the session ids passed as argument.
stmt := Db().Where("user_uid = ? AND id NOT IN (?)", m.UserUID, omit)
sess := Sessions{}
if err := stmt.Find(&sess).Error; err != nil {
event.AuditErr([]string{"user %s", "failed to invalidate sessions", "%s"}, m.RefID, err)
return 0
}
// This will also remove the session from the cache.
for _, s := range sess {
if err := s.Delete(); err != nil {
event.AuditWarn([]string{"user %s", "failed to invalidate session %s", "%s"}, m.RefID, clean.Log(s.RefID), err)
} else {
deleted++
}
}
// Return number of deleted sessions for logs.
return deleted
}
// SetPassword sets a new password stored as hash.
func (m *User) SetPassword(password string) error {
if !m.IsRegistered() {
return fmt.Errorf("only registered users may change their password")
}
if len(password) < LenPasswordMin {
return fmt.Errorf("password must have at least %d characters", LenPasswordMin)
}
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 {
// Registered user?
if !m.IsRegistered() {
log.Warn("only registered users may change their password")
return true
}
// Empty password?
if password == "" {
return true
}
// Fetch password.
pw := FindPassword(m.UserUID)
// Found?
if pw == nil {
return true
}
// Invalid?
if pw.InvalidPassword(password) {
return true
}
return false
}
// Validate checks if username, email and role are valid and returns an error otherwise.
func (m *User) Validate() (err error) {
// Empty name?
if m.Name() == "" {
return errors.New("username cannot be empty")
}
// Name too short?
if len(m.Name()) < LenNameMin {
return fmt.Errorf("username must have at least %d characters", LenNameMin)
}
// Validate user role.
if acl.ValidRoles[m.UserRole] == "" {
return fmt.Errorf("role %s is invalid", clean.LogQuote(m.UserRole))
}
// Check if the username is unique.
var duplicate = User{}
if err = Db().
Where("user_name = ? AND id <> ?", m.UserName, m.ID).
First(&duplicate).Error; err == nil {
return fmt.Errorf("username %s already exists", clean.LogQuote(m.UserName))
} else if err != gorm.ErrRecordNotFound {
return err
}
// Skip email check?
if m.UserEmail == "" {
return nil
}
// Parse and validate email address.
if a, err := mail.ParseAddress(m.UserEmail); err != nil {
return fmt.Errorf("email %s is invalid", clean.LogQuote(m.UserEmail))
} else if email := a.Address; !strings.ContainsRune(email, '.') {
return fmt.Errorf("email %s does not have a fully qualified domain", clean.LogQuote(m.UserEmail))
} else {
m.UserEmail = email
}
// Check if the email is unique.
if err = Db().
Where("user_email = ? AND id <> ?", m.UserEmail, m.ID).
First(&duplicate).Error; err == nil {
return fmt.Errorf("email %s already exists", clean.Log(m.UserEmail))
} else if err != gorm.ErrRecordNotFound {
return err
}
return nil
}
// SetFormValues sets the values specified in the form.
func (m *User) SetFormValues(frm form.User) *User {
m.UserName = frm.Name()
m.UserEmail = frm.Email()
m.DisplayName = frm.DisplayName
m.SuperAdmin = frm.SuperAdmin
m.CanLogin = frm.CanLogin
m.CanSync = frm.CanSync
m.UserRole = frm.Role()
m.UserAttr = frm.Attr()
m.SetBasePath(frm.BasePath)
m.SetUploadPath(frm.UploadPath)
return m
}
// RefreshShares updates the list of shares.
func (m *User) RefreshShares() *User {
m.Shares = FindShares(m.UID())
return m
}
// NoShares checks if the user has no shares yet.
func (m *User) NoShares() bool {
if !m.IsRegistered() {
return true
}
return m.Shares.Empty()
}
// HasShares checks if the user has any shares.
func (m *User) HasShares() bool {
return !m.NoShares()
}
// HasShare if a uid was shared with the user.
func (m *User) HasShare(uid string) bool {
if !m.IsRegistered() || m.NoShares() {
return false
}
return m.Shares.Contains(uid)
}
// SharedUIDs returns shared entity UIDs.
func (m *User) SharedUIDs() UIDs {
if m.IsRegistered() && m.Shares.Empty() {
m.RefreshShares()
}
return m.Shares.UIDs()
}
// RedeemToken updates shared entity UIDs using the specified token.
func (m *User) RedeemToken(token string) (n int) {
if !m.IsRegistered() {
return 0
}
// Find links.
links := FindValidLinks(token, "")
// Found?
if n = len(links); n == 0 {
return n
}
// Find shares.
for _, link := range links {
if found := FindShare(Share{UserUID: m.UID(), ShareUID: link.ShareUID}); found == nil {
share := NewShare(m.UID(), link.ShareUID, link.Perm, link.ExpiresAt())
share.LinkUID = link.LinkUID
share.Comment = link.Comment
if err := share.Save(); err != nil {
event.AuditErr([]string{"user %s", "token %s", "failed to redeem shares", "%s"}, m.RefID, clean.Log(token), err)
} else {
link.Redeem()
}
} else if err := found.UpdateLink(link); err != nil {
event.AuditErr([]string{"user %s", "token %s", "failed to update shares", "%s"}, m.RefID, clean.Log(token), err)
}
}
return n
}