Auth: Refactor sessions API and model #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-03-09 18:45:04 +01:00
parent 9db0424c05
commit 0a5dce5aeb
11 changed files with 222 additions and 24 deletions

View file

@ -471,7 +471,7 @@
<v-list-tile v-if="canManageUsers" :to="{ path: '/admin/users' }" :exact="false" class="nav-admin-users" @click.stop="">
<v-list-tile-content>
<v-list-tile-title :class="`menu-item ${rtl ? '--rtl' : ''}`">
<translate>Users</translate>
<translate>Authentication</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>

View file

@ -5,9 +5,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// DeleteSession deletes an existing client session (logout).
@ -17,6 +20,7 @@ func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) {
id := clean.ID(c.Param("id"))
// Abort if ID is missing.
if id == "" {
AbortBadRequest(c)
return
@ -25,6 +29,23 @@ func DeleteSession(router *gin.RouterGroup) {
return
}
// Find session by reference ID.
if !rnd.IsRefID(id) {
// Do nothing.
} else if s := Session(SessionID(c)); s == nil {
entity.SessionStatusUnauthorized().Abort(c)
return
} else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
s.Abort(c)
return
} else if ref := entity.FindSessionByRefID(id); ref == nil {
AbortNotFound(c)
return
} else {
id = ref.ID
}
// Delete session by ID.
if err := get.Session().Delete(id); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s"}, err)
} else {

View file

@ -29,33 +29,33 @@ type Sessions []Session
// Session represents a User session.
type Session struct {
ID string `gorm:"type:VARBINARY(2048);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
ClientIP string `gorm:"size:64;column:client_ip;index" json:"-" yaml:"ClientIP,omitempty"`
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName,omitempty" yaml:"UserName,omitempty"`
ID string `gorm:"type:VARBINARY(2048);primary_key;auto_increment:false;" json:"-" yaml:"ID"`
ClientIP string `gorm:"size:64;column:client_ip;index" json:"ClientIP" yaml:"ClientIP,omitempty"`
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
user *User `gorm:"-"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthProvider,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthMethod,omitempty"`
AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"-" yaml:"AuthDomain,omitempty"`
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"-" yaml:"AuthID,omitempty"`
AuthScope string `gorm:"size:1024;default:'';" json:"-" yaml:"AuthScope,omitempty"`
LastActive int64 `json:"LastActive,omitempty" yaml:"LastActive,omitempty"`
SessExpires int64 `gorm:"index" json:"Expires,omitempty" yaml:"Expires,omitempty"`
SessTimeout int64 `json:"Timeout,omitempty" yaml:"Timeout,omitempty"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"AuthDomain" yaml:"AuthDomain,omitempty"`
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"`
SessExpires int64 `gorm:"index" json:"Expires" yaml:"Expires,omitempty"`
SessTimeout int64 `json:"Timeout" yaml:"Timeout,omitempty"`
PreviewToken string `gorm:"type:VARBINARY(64);column:preview_token;default:'';" json:"-" yaml:"-"`
DownloadToken string `gorm:"type:VARBINARY(64);column:download_token;default:'';" json:"-" yaml:"-"`
AccessToken string `gorm:"type:VARBINARY(4096);column:access_token;default:'';" json:"-" yaml:"-"`
RefreshToken string `gorm:"type:VARBINARY(512);column:refresh_token;default:'';" json:"-" yaml:"-"`
IdToken string `gorm:"type:VARBINARY(1024);column:id_token;default:'';" json:"IdToken,omitempty" yaml:"IdToken,omitempty"`
UserAgent string `gorm:"size:512;" json:"-" yaml:"UserAgent,omitempty"`
DataJSON json.RawMessage `gorm:"type:VARBINARY(4096);" json:"Data,omitempty" yaml:"Data,omitempty"`
UserAgent string `gorm:"size:512;" json:"UserAgent" yaml:"UserAgent,omitempty"`
DataJSON json.RawMessage `gorm:"type:VARBINARY(4096);" json:"-" yaml:"Data,omitempty"`
data *SessionData `gorm:"-"`
RefID string `gorm:"type:VARBINARY(16);default:'';" json:"-" yaml:"-"`
LoginIP string `gorm:"size:64;column:login_ip" json:"-" yaml:"-"`
LoginAt time.Time `json:"-" yaml:"-"`
RefID string `gorm:"type:VARBINARY(16);default:'';" json:"ID" yaml:"-"`
LoginIP string `gorm:"size:64;column:login_ip" json:"LoginIP" yaml:"-"`
LoginAt time.Time `json:"LoginAt" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt"`
Status int `gorm:"-" json:"Status,omitempty" yaml:"-"`
Status int `gorm:"-" json:"Status" yaml:"-"`
}
// TableName returns the entity table name.
@ -125,6 +125,22 @@ func SessionStatusForbidden() *Session {
return &Session{Status: http.StatusForbidden}
}
// FindSessionByRefID finds an existing session by ref ID.
func FindSessionByRefID(refId string) *Session {
if !rnd.IsRefID(refId) {
return nil
}
m := &Session{}
// Build query.
if err := UnscopedDb().Where("ref_id = ?", refId).First(m).Error; err != nil {
return nil
}
return m
}
// RegenerateID regenerated the random session ID.
func (m *Session) RegenerateID() *Session {
if m.ID == "" {

View file

@ -0,0 +1,22 @@
package form
// SearchSessions represents a session search form.
type SearchSessions struct {
Query string `form:"q"`
UID string `form:"uid"`
Count int `form:"count" binding:"required" serialize:"-"`
Offset int `form:"offset" serialize:"-"`
Order string `form:"order" serialize:"-"`
}
func (f *SearchSessions) GetQuery() string {
return f.Query
}
func (f *SearchSessions) SetQuery(q string) {
f.Query = q
}
func (f *SearchSessions) ParseQueryString() error {
return ParseQueryString(f)
}

View file

@ -22,7 +22,3 @@ func (f *SearchUsers) SetQuery(q string) {
func (f *SearchUsers) ParseQueryString() error {
return ParseQueryString(f)
}
func NewSearchUsers(query string) SearchUsers {
return SearchUsers{Query: query}
}

View file

@ -35,7 +35,7 @@ func Sessions(limit, offset int, sortOrder, search string) (result entity.Sessio
} else if rnd.IsUID(search, entity.UserUID) {
stmt = stmt.Where("user_uid = ?", search)
} else if search != "" {
stmt = stmt.Where("user_name LIKE ?", search+"%")
stmt = stmt.Where("user_name LIKE ? OR auth_provider LIKE ?", search+"%", search+"%")
}
if sortOrder == "" {

View file

@ -0,0 +1,45 @@
package search
import (
"strings"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Sessions finds user sessions.
func Sessions(f form.SearchSessions) (result entity.Sessions, err error) {
result = entity.Sessions{}
stmt := Db()
search := strings.TrimSpace(f.Query)
uid := strings.TrimSpace(f.UID)
sortOrder := f.Order
limit := f.Count
offset := f.Offset
if search == "all" {
// Don't filter.
} else if rnd.IsUID(uid, entity.UserUID) {
stmt = stmt.Where("user_uid = ?", search)
} else if search != "" {
stmt = stmt.Where("user_name LIKE ? OR auth_provider LIKE ?", search+"%", search+"%")
}
if sortOrder == "" {
sortOrder = "last_active, user_name"
}
if limit > 0 {
stmt = stmt.Limit(limit)
if offset > 0 {
stmt = stmt.Offset(offset)
}
}
err = stmt.Order(sortOrder).Find(&result).Error
return result, err
}

View file

@ -0,0 +1,49 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form"
)
func TestSessions(t *testing.T) {
t.Run("Default", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{}); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 2, len(results))
//t.Logf("sessions: %#v", results)
}
})
t.Run("Limit", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Count: 1}); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 1, len(results))
//t.Logf("sessions: %#v", results)
}
})
t.Run("Offset", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Offset: 1}); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 2, len(results))
//t.Logf("sessions: %#v", results)
}
})
t.Run("SearchAlice", func(t *testing.T) {
if results, err := Sessions(form.SearchSessions{Count: 100, Query: "alice"}); err != nil {
t.Fatal(err)
} else {
t.Logf("sessions: %#v", results)
assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 {
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", results[0].ID)
assert.Equal(t, "uqxetse3cy5eo9z2", results[0].UserUID)
assert.Equal(t, "alice", results[0].UserName)
}
}
})
}

View file

@ -0,0 +1,49 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form"
)
func TestUsers(t *testing.T) {
t.Run("Default", func(t *testing.T) {
if results, err := Users(form.SearchUsers{}); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 2, len(results))
//t.Logf("sessions: %#v", results)
}
})
t.Run("Limit", func(t *testing.T) {
if results, err := Users(form.SearchUsers{Count: 1}); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 1, len(results))
//t.Logf("sessions: %#v", results)
}
})
t.Run("Offset", func(t *testing.T) {
if results, err := Users(form.SearchUsers{Offset: 1}); err != nil {
t.Fatal(err)
} else {
assert.LessOrEqual(t, 2, len(results))
//t.Logf("sessions: %#v", results)
}
})
t.Run("SearchAlice", func(t *testing.T) {
if results, err := Users(form.SearchUsers{Count: 100, Query: "alice"}); err != nil {
t.Fatal(err)
} else {
t.Logf("users: %#v", results)
assert.LessOrEqual(t, 1, len(results))
if len(results) > 0 {
assert.Equal(t, 5, results[0].ID)
assert.Equal(t, "uqxetse3cy5eo9z2", results[0].UserUID)
assert.Equal(t, "alice", results[0].UserName)
}
}
})
}