Auth: Add client_uid and client_name to auth_sessions table #808 #3943

This also adds the ability to change the client role if needed and
improves the usage information and output of the CLI commands.

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-18 16:53:05 +01:00
parent 392bb1d5cf
commit 7e7ba69982
44 changed files with 530 additions and 238 deletions

View file

@ -65,10 +65,6 @@ func (a *AuthRequest) GetClientID() string {
return a.ClientID
}
//func (a *AuthRequest) GetCode() string {
// return "GetCode"
//}
//
func (a *AuthRequest) GetCodeChallenge() *oidc.CodeChallenge {
fmt.Println("GetCodeChallenge: ", a.CodeChallenge.Challenge, a.CodeChallenge.Method)
return a.CodeChallenge

View file

@ -16,8 +16,8 @@ func (r Role) String() string {
// Pretty returns the type in an easy-to-read format.
func (r Role) Pretty() string {
if r == RoleUnknown {
return "Unknown"
if r == RoleNone {
return "None"
}
return txt.UpperFirst(string(r))
@ -40,7 +40,7 @@ func (r Role) NotEqual(s string) bool {
// Valid checks if the role is valid.
func (r Role) Valid(s string) bool {
return ValidRoles[s] != ""
return UserRoles[s] != ""
}
// Invalid checks if the role is invalid.

View file

@ -22,8 +22,8 @@ func TestRole_Pretty(t *testing.T) {
t.Run("Admin", func(t *testing.T) {
assert.Equal(t, "Admin", RoleAdmin.Pretty())
})
t.Run("Unknown", func(t *testing.T) {
assert.Equal(t, "Unknown", RoleUnknown.Pretty())
t.Run("None", func(t *testing.T) {
assert.Equal(t, "None", RoleNone.Pretty())
})
t.Run("Visitor", func(t *testing.T) {
assert.Equal(t, "Visitor", RoleVisitor.Pretty())

View file

@ -6,17 +6,24 @@ const (
RoleAdmin Role = "admin"
RoleVisitor Role = "visitor"
RoleClient Role = "client"
RoleUnknown Role = ""
RoleNone Role = ""
)
// RoleStrings represents user role names mapped to roles.
type RoleStrings = map[string]Role
// ValidRoles specifies the valid user roles.
var ValidRoles = RoleStrings{
// UserRoles maps valid user account roles.
var UserRoles = RoleStrings{
string(RoleAdmin): RoleAdmin,
string(RoleVisitor): RoleVisitor,
string(RoleUnknown): RoleUnknown,
string(RoleNone): RoleNone,
}
// ClientRoles maps valid API client roles.
var ClientRoles = RoleStrings{
string(RoleAdmin): RoleAdmin,
string(RoleClient): RoleClient,
string(RoleNone): RoleNone,
}
// Roles grants permissions to roles.

View file

@ -6,6 +6,7 @@ import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header"
)
@ -42,31 +43,31 @@ func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s *
if s.IsClient() {
// Check ACL resource name against the permitted scope.
if !s.HasScope(resource.String()) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "access %s", "denied"}, s.AuthID, s.RefID, string(resource))
event.AuditErr([]string{clientIp, "client %s", "session %s", "access %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, string(resource))
return entity.SessionStatusForbidden()
}
// Perform an authorization check based on the ACL defaults for client applications.
if acl.Resources.DenyAll(resource, acl.RoleClient, grants) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource))
if acl.Resources.DenyAll(resource, s.ClientRole(), grants) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource))
return entity.SessionStatusForbidden()
}
// Additionally check the user authorization if the client belongs to a user account.
if s.NoUser() {
// Allow access based on the ACL defaults for client applications.
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", "granted"}, s.AuthID, s.RefID, grants.String(), string(resource))
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource))
} else if u := s.User(); !u.IsDisabled() && !u.IsUnknown() && u.IsRegistered() {
if acl.Resources.DenyAll(resource, u.AclRole(), grants) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource), u.String())
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource), u.String())
return entity.SessionStatusForbidden()
}
// Allow access based on the user role.
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", "granted"}, s.AuthID, s.RefID, grants.String(), string(resource), u.String())
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource), u.String())
} else {
// Deny access if it is not a regular user account or the account has been disabled.
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as unauthorized user", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource))
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as unauthorized user", "denied"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource))
return entity.SessionStatusForbidden()
}

View file

@ -367,7 +367,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
var err error
// Abort if user wants to delete all but does not have sufficient privileges.
if f.All && !acl.Resources.AllowAll(acl.ResourcePhotos, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
if f.All && !acl.Resources.AllowAll(acl.ResourcePhotos, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
AbortForbidden(c)
return
}

View file

@ -91,7 +91,7 @@ func SaveSettings(router *gin.RouterGroup) {
return
}
if acl.Resources.DenyAll(acl.ResourceSettings, s.User().AclRole(), acl.Permissions{acl.ActionUpdate, acl.ActionManage}) {
if acl.Resources.DenyAll(acl.ResourceSettings, s.UserRole(), acl.Permissions{acl.ActionUpdate, acl.ActionManage}) {
c.JSON(http.StatusOK, user.Settings().Apply(settings).ApplyTo(conf.Settings().ApplyACL(acl.Resources, user.AclRole())))
return
} else if err := user.Settings().Apply(settings).Save(); err != nil {

View file

@ -67,9 +67,9 @@ func StartImport(router *gin.RouterGroup) {
// To avoid conflicts, uploads are imported from "import_path/upload/session_ref/timestamp".
if token := path.Base(srcFolder); token != "" && path.Dir(srcFolder) == UploadPath {
srcFolder = path.Join(UploadPath, s.RefID+token)
event.AuditInfo([]string{ClientIP(c), "session %s", "import uploads from %s as %s", "granted"}, s.RefID, clean.Log(srcFolder), s.User().AclRole().String())
} else if acl.Resources.Deny(acl.ResourceFiles, s.User().AclRole(), acl.ActionManage) {
event.AuditErr([]string{ClientIP(c), "session %s", "import files from %s as %s", "denied"}, s.RefID, clean.Log(srcFolder), s.User().AclRole().String())
event.AuditInfo([]string{ClientIP(c), "session %s", "import uploads from %s as %s", "granted"}, s.RefID, clean.Log(srcFolder), s.UserRole().String())
} else if acl.Resources.Deny(acl.ResourceFiles, s.UserRole(), acl.ActionManage) {
event.AuditErr([]string{ClientIP(c), "session %s", "import files from %s as %s", "denied"}, s.RefID, clean.Log(srcFolder), s.UserRole().String())
AbortForbidden(c)
return
}
@ -99,7 +99,7 @@ func StartImport(router *gin.RouterGroup) {
// Add imported files to albums if allowed.
if len(f.Albums) > 0 &&
acl.Resources.AllowAny(acl.ResourceAlbums, s.User().AclRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
acl.Resources.AllowAny(acl.ResourceAlbums, s.UserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
log.Debugf("import: adding files to album %s", clean.Log(strings.Join(f.Albums, " and ")))
opt.Albums = f.Albums
}

View file

@ -46,7 +46,7 @@ func SearchPhotos(router *gin.RouterGroup) {
// Ignore private flag if feature is disabled.
if f.Scope == "" &&
settings.Features.Review &&
acl.Resources.Deny(acl.ResourcePhotos, s.User().AclRole(), acl.ActionManage) {
acl.Resources.Deny(acl.ResourcePhotos, s.UserRole(), acl.ActionManage) {
f.Quality = 3
}

View file

@ -50,7 +50,7 @@ func SearchGeo(router *gin.RouterGroup) {
// Ignore private flag if feature is disabled.
if f.Scope == "" &&
settings.Features.Review &&
acl.Resources.Deny(acl.ResourcePhotos, s.User().AclRole(), acl.ActionManage) {
acl.Resources.Deny(acl.ResourcePhotos, s.UserRole(), acl.ActionManage) {
f.Quality = 3
}

View file

@ -31,11 +31,11 @@ func LikePhoto(router *gin.RouterGroup) {
return
}
if get.Config().Experimental() && acl.Resources.Allow(acl.ResourcePhotos, s.User().AclRole(), acl.ActionReact) {
if get.Config().Experimental() && acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionReact) {
logWarn("react", m.React(s.User(), react.Find("love")))
}
if acl.Resources.Allow(acl.ResourcePhotos, s.User().AclRole(), acl.ActionUpdate) {
if acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionUpdate) {
err = m.SetFavorite(true)
if err != nil {
@ -71,11 +71,11 @@ func DislikePhoto(router *gin.RouterGroup) {
return
}
if get.Config().Experimental() && acl.Resources.Allow(acl.ResourcePhotos, s.User().AclRole(), acl.ActionReact) {
if get.Config().Experimental() && acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionReact) {
logWarn("react", m.UnReact(s.User()))
}
if acl.Resources.Allow(acl.ResourcePhotos, s.User().AclRole(), acl.ActionUpdate) {
if acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionUpdate) {
err = m.SetFavorite(false)
if err != nil {

View file

@ -49,27 +49,27 @@ func DeleteSession(router *gin.RouterGroup) {
// Only admins may delete other sessions by ref id.
if rnd.IsRefID(id) {
if !acl.Resources.AllowAll(acl.ResourceSessions, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", "denied"}, s.RefID, acl.ResourceSessions.String(), s.User().AclRole())
if !acl.Resources.AllowAll(acl.ResourceSessions, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", "denied"}, s.RefID, acl.ResourceSessions.String(), s.UserRole())
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", "granted"}, s.RefID, acl.ResourceSessions.String(), s.User().AclRole())
event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", "granted"}, s.RefID, acl.ResourceSessions.String(), s.UserRole())
if s = entity.FindSessionByRefID(id); s == nil {
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
return
}
} else if id != "" && s.ID != id {
event.AuditWarn([]string{clientIp, "session %s", "delete %s as %s", "ids do not match"}, s.RefID, acl.ResourceSessions.String(), s.User().AclRole())
event.AuditWarn([]string{clientIp, "session %s", "delete %s as %s", "ids do not match"}, s.RefID, acl.ResourceSessions.String(), s.UserRole())
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
// Delete session cache and database record.
if err := s.Delete(); err != nil {
event.AuditErr([]string{clientIp, "session %s", "delete session as %s", "%s"}, s.RefID, s.User().AclRole(), err)
event.AuditErr([]string{clientIp, "session %s", "delete session as %s", "%s"}, s.RefID, s.UserRole(), err)
} else {
event.AuditDebug([]string{clientIp, "session %s", "deleted"}, s.RefID)
}

View file

@ -6,7 +6,6 @@ import (
"github.com/dustin/go-humanize/english"
"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/form"
@ -73,7 +72,7 @@ func CreateOAuthToken(router *gin.RouterGroup) {
}
// Find the client that has the ID specified in the authentication request.
client := entity.FindClient(f.ClientID)
client := entity.FindClientByUID(f.ClientID)
// Abort if the client ID or secret are invalid.
if client == nil {
@ -181,28 +180,28 @@ func RevokeOAuthToken(router *gin.RouterGroup) {
sess, err := entity.FindSession(rnd.SessionID(f.AuthToken))
if err != nil {
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String(), err.Error())
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String(), err.Error())
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
return
} else if sess == nil {
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
return
} else if sess.Abort(c) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
return
} else if !sess.IsClient() {
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
c.AbortWithStatusJSON(http.StatusForbidden, i18n.NewResponse(http.StatusForbidden, i18n.ErrForbidden))
return
} else {
event.AuditInfo([]string{clientIp, "client %s", "session %s", "delete session as %s", "granted"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
event.AuditInfo([]string{clientIp, "client %s", "session %s", "delete session as %s", "granted"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
}
// Delete session cache and database record.
if err = sess.Delete(); err != nil {
// Log error.
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String(), err)
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String(), err)
// Return JSON error.
c.AbortWithStatusJSON(http.StatusNotFound, i18n.NewResponse(http.StatusNotFound, i18n.ErrNotFound))
@ -210,7 +209,7 @@ func RevokeOAuthToken(router *gin.RouterGroup) {
}
// Log event.
event.AuditInfo([]string{clientIp, "client %s", "session %s", "deleted"}, clean.Log(sess.AuthID), clean.Log(sess.RefID))
event.AuditInfo([]string{clientIp, "client %s", "session %s", "deleted"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID))
// Return JSON response for confirmation.
c.JSON(http.StatusOK, DeleteSessionResponse(sess.ID))

View file

@ -36,7 +36,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
}
// Check if the session user is has user management privileges.
isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
uid := clean.UID(c.Param("uid"))
// Users may only change their own avatar.

View file

@ -43,7 +43,7 @@ func UpdateUserPassword(router *gin.RouterGroup) {
}
// Check if the session user is has user management privileges.
isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
isSuperAdmin := isAdmin && s.User().IsSuperAdmin()
uid := clean.UID(c.Param("uid"))

View file

@ -61,7 +61,7 @@ func UpdateUser(router *gin.RouterGroup) {
}
// Check if the session user is has user management privileges.
isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
privilegeLevelChange := isAdmin && m.PrivilegeLevelChange(f)
// Prevent super admins from locking themselves out.

View file

@ -197,7 +197,7 @@ func ProcessUserUpload(router *gin.RouterGroup) {
// Add imported files to albums if allowed.
if len(f.Albums) > 0 &&
acl.Resources.AllowAny(acl.ResourceAlbums, s.User().AclRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
acl.Resources.AllowAny(acl.ResourceAlbums, s.UserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) {
log.Debugf("upload: adding files to album %s", clean.Log(strings.Join(f.Albums, " and ")))
opt.Albums = f.Albums
}

View file

@ -17,15 +17,15 @@ import (
var AuthAddFlags = []cli.Flag{
cli.StringFlag{
Name: "name, n",
Usage: "arbitrary name to help identify the access `TOKEN`",
Usage: "access `TOKEN` name to help identify the client application",
},
cli.StringFlag{
Name: "scope, s",
Usage: "authorization `SCOPE` for the access token e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all scopes)",
Usage: "authorization `SCOPES` e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all)",
},
cli.Int64Flag{
Name: "expires, e",
Usage: "access token lifetime in `SECONDS`, after which it expires and a new token must be created (-1 to disable)",
Usage: "authentication `LIFETIME` in seconds, after which the access token expires (-1 to disable the limit)",
Value: entity.UnixYear,
},
}
@ -53,10 +53,10 @@ func authAddAction(ctx *cli.Context) error {
return fmt.Errorf("user %s not found", clean.LogQuote(userName))
}
// Get token name from command flag or ask for it.
tokenName := ctx.String("name")
// Get client name from command flag or ask for it.
clientName := ctx.String("name")
if tokenName == "" {
if clientName == "" {
prompt := promptui.Prompt{
Label: "Token Name",
Default: rnd.Name(),
@ -68,7 +68,7 @@ func authAddAction(ctx *cli.Context) error {
return err
}
tokenName = clean.Name(res)
clientName = clean.Name(res)
}
// Get auth scope from command flag or ask for it.
@ -89,8 +89,8 @@ func authAddAction(ctx *cli.Context) error {
authScope = clean.Scope(res)
}
// Create client session.
sess, err := entity.CreateClientAccessToken(tokenName, ctx.Int64("expires"), authScope, user)
// Create session with client access token.
sess, err := entity.CreateClientAccessToken(clientName, ctx.Int64("expires"), authScope, user)
if err != nil {
return fmt.Errorf("failed to create access token: %s", err)

View file

@ -25,7 +25,7 @@ func authListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
var rows [][]string
cols := []string{"Session ID", "User", "Authentication", "Scope", "Identifier", "Client IP", "Last Active", "Created At", "Expires At"}
cols := []string{"Session ID", "Session User", "Session Client", "Authentication Method", "Scope", "Login IP", "Current IP", "Last Active", "Created At", "Expires At"}
if ctx.Bool("tokens") {
cols = append(cols, "Preview Token", "Download Token")
@ -49,18 +49,13 @@ func authListAction(ctx *cli.Context) error {
// Display report.
for i, res := range results {
user := res.Username()
if user == "" {
user = res.User().UserRole
}
rows[i] = []string{
res.RefID,
user,
res.UserInfo(),
res.ClientInfo(),
res.AuthInfo(),
res.AuthScope,
res.AuthID,
res.LoginIP,
res.ClientIP,
report.UnixTime(res.LastActive),
report.DateTime(&res.CreatedAt),

View file

@ -41,7 +41,7 @@ func TestAuthListCommand(t *testing.T) {
// Check command output for plausibility.
// t.Logf(output)
assert.NoError(t, err)
assert.Contains(t, output, "| Session ID |")
assert.Contains(t, output, "Session ID")
assert.Contains(t, output, "alice")
assert.NotContains(t, output, "bob")
assert.NotContains(t, output, "visitor")
@ -60,7 +60,7 @@ func TestAuthListCommand(t *testing.T) {
// Check command output for plausibility.
t.Logf(output)
assert.NoError(t, err)
assert.Contains(t, output, "Session ID;User;Authentication")
assert.Contains(t, output, "Session ID;")
assert.Contains(t, output, "alice")
assert.NotContains(t, output, "bob")
assert.NotContains(t, output, "visitor")

View file

@ -3,19 +3,22 @@ package commands
import (
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/authn"
)
// Usage hints for the client management subcommands.
const (
ClientNameUsage = "arbitrary name to help identify the `CLIENT` application"
ClientAuthScope = "authorization `SCOPE` of the client e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all scopes)"
ClientAuthMethod = "supported authentication `METHOD` for the client application"
ClientAuthExpires = "access token lifetime in `SECONDS`, after which a new token must be created by the client (-1 to disable)"
ClientAuthTokens = "maximum `NUMBER` of access tokens the client can create (-1 to disable)"
ClientNameUsage = "`CLIENT` name to help identify the application"
ClientRoleUsage = "client authorization `ROLE`"
ClientAuthScope = "client authorization `SCOPES` e.g. \"metrics\" or \"photos albums\" (\"*\" to allow all)"
ClientAuthMethod = "client authentication `METHOD`"
ClientAuthExpires = "authentication `LIFETIME` in seconds, after which a new access token must be requested (-1 to disable the limit)"
ClientAuthTokens = "maximum 'NUMBER' of access tokens that the client can request (-1 to disable the limit)"
ClientRegenerateSecret = "generate a new client secret and display it"
ClientDisable = "deactivate authentication with this client"
ClientEnable = "re-enable client authentication"
ClientEnable = "enable client authentication if disabled"
ClientDisable = "disable client authentication"
)
// ClientsCommands configures the client application subcommands.
@ -39,6 +42,11 @@ var ClientAddFlags = []cli.Flag{
Name: "name, n",
Usage: ClientNameUsage,
},
cli.StringFlag{
Name: "role, r",
Usage: ClientRoleUsage,
Value: acl.RoleClient.String(),
},
cli.StringFlag{
Name: "scope, s",
Usage: ClientAuthScope,
@ -52,10 +60,12 @@ var ClientAddFlags = []cli.Flag{
cli.Int64Flag{
Name: "expires, e",
Usage: ClientAuthExpires,
Value: entity.UnixDay,
},
cli.Int64Flag{
Name: "tokens, t",
Usage: ClientAuthTokens,
Value: 10,
},
}
@ -65,6 +75,11 @@ var ClientModFlags = []cli.Flag{
Name: "name, n",
Usage: ClientNameUsage,
},
cli.StringFlag{
Name: "role, r",
Usage: ClientRoleUsage,
Value: acl.RoleClient.String(),
},
cli.StringFlag{
Name: "scope, s",
Usage: ClientAuthScope,
@ -87,12 +102,12 @@ var ClientModFlags = []cli.Flag{
Name: "regenerate-secret, r",
Usage: ClientRegenerateSecret,
},
cli.BoolFlag{
Name: "disable",
Usage: ClientDisable,
},
cli.BoolFlag{
Name: "enable",
Usage: ClientEnable,
},
cli.BoolFlag{
Name: "disable",
Usage: ClientDisable,
},
}

View file

@ -87,18 +87,9 @@ func clientsAddAction(ctx *cli.Context) error {
log.Infof("successfully registered new client %s", clean.LogQuote(client.ClientName))
// Display client details.
cols := []string{"Client ID", "Client Name", "Authentication", "Scope", "User", "Enabled", "Access Token Expires", "Created At"}
cols := []string{"Client ID", "Client Name", "Client User", "Authentication Method", "Role", "Scope", "Enabled", "Authentication Expires", "Created At"}
rows := make([][]string, 1)
var userName string
if client.UserUID == "" {
userName = report.NotAssigned
} else if client.UserName != "" {
userName = client.UserName
} else {
userName = client.UserUID
}
var authExpires string
if client.AuthExpires > 0 {
authExpires = client.Expires().String()
@ -107,15 +98,16 @@ func clientsAddAction(ctx *cli.Context) error {
}
if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s, max %d tokens", authExpires, client.AuthTokens)
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
}
rows[0] = []string{
client.UID(),
client.ClientName,
client.AuthMethod,
client.AuthScope,
userName,
client.Name(),
client.UserInfo(),
client.Method().String(),
client.AclRole().String(),
client.Scope(),
report.Bool(client.AuthEnabled, report.Yes, report.No),
authExpires,
client.CreatedAt.Format("2006-01-02 15:04:05"),

View file

@ -23,7 +23,7 @@ var ClientsListCommand = cli.Command{
// clientsListAction lists registered client applications
func clientsListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"Client ID", "Client Name", "Authentication", "Scope", "User", "Enabled", "Access Token Expires", "Created At"}
cols := []string{"Client ID", "Client Name", "Client User", "Authentication Method", "Role", "Scope", "Enabled", "Authentication Expires", "Created At"}
// Fetch clients from database.
clients, err := query.Clients(ctx.Int("n"), 0, "", ctx.Args().First())
@ -44,15 +44,6 @@ func clientsListAction(ctx *cli.Context) error {
// Display report.
for i, client := range clients {
var userName string
if client.UserUID == "" {
userName = report.NotAssigned
} else if client.UserName != "" {
userName = client.UserName
} else {
userName = client.UserUID
}
var authExpires string
if client.AuthExpires > 0 {
authExpires = client.Expires().String()
@ -61,15 +52,16 @@ func clientsListAction(ctx *cli.Context) error {
}
if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s, max %d tokens", authExpires, client.AuthTokens)
authExpires = fmt.Sprintf("%s; up to %d tokens", authExpires, client.AuthTokens)
}
rows[i] = []string{
client.UID(),
client.ClientName,
client.AuthMethod,
client.AuthScope,
userName,
client.Name(),
client.UserInfo(),
client.Method().String(),
client.AclRole().String(),
client.Scope(),
report.Bool(client.AuthEnabled, report.Yes, report.No),
authExpires,
client.CreatedAt.Format("2006-01-02 15:04:05"),

View file

@ -37,7 +37,7 @@ func clientsModAction(ctx *cli.Context) error {
// Find client record.
var client *entity.Client
client = entity.FindClient(id)
client = entity.FindClientByUID(id)
if client == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id))

View file

@ -41,7 +41,7 @@ func clientsRemoveAction(ctx *cli.Context) error {
// Find client record.
var m *entity.Client
m = entity.FindClient(id)
m = entity.FindClientByUID(id)
if m == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id))

View file

@ -33,7 +33,7 @@ func clientsShowAction(ctx *cli.Context) error {
// Find client record.
var m *entity.Client
m = entity.FindClient(id)
m = entity.FindClientByUID(id)
if m == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id))

View file

@ -228,7 +228,7 @@ func (c *Config) ClientPublic() ClientConfig {
cfg := ClientConfig{
Settings: c.PublicSettings(),
ACL: acl.Resources.Grants(acl.RoleUnknown),
ACL: acl.Resources.Grants(acl.RoleNone),
Disable: ClientDisable{
WebDAV: true,
Settings: c.DisableSettings(),
@ -683,12 +683,12 @@ func (c *Config) ClientRole(role acl.Role) ClientConfig {
// ClientSession provides the client config values for the specified session.
func (c *Config) ClientSession(sess *entity.Session) (cfg ClientConfig) {
if sess.NoUser() && sess.IsClient() {
cfg = c.ClientUser(false).ApplyACL(acl.Resources, acl.RoleClient)
cfg = c.ClientUser(false).ApplyACL(acl.Resources, sess.ClientRole())
cfg.Settings = c.SessionSettings(sess)
} else if sess.User().IsVisitor() {
cfg = c.ClientShare()
} else if sess.User().IsRegistered() {
cfg = c.ClientUser(false).ApplyACL(acl.Resources, sess.User().AclRole())
cfg = c.ClientUser(false).ApplyACL(acl.Resources, sess.UserRole())
cfg.Settings = c.SessionSettings(sess)
} else {
cfg = c.ClientPublic()

View file

@ -167,8 +167,8 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
assert.Equal(t, expected, f)
})
t.Run("RoleUnknown", func(t *testing.T) {
cfg := c.ClientRole(acl.RoleUnknown)
t.Run("RoleNone", func(t *testing.T) {
cfg := c.ClientRole(acl.RoleNone)
f := cfg.Settings.Features
assert.NotEqual(t, adminFeatures, f)
@ -355,7 +355,7 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Review)
assert.False(t, f.Share)
})
t.Run("RoleUnknown", func(t *testing.T) {
t.Run("RoleNone", func(t *testing.T) {
sess := entity.SessionFixtures.Pointer("unauthorized")
cfg := c.ClientSession(sess)

View file

@ -7,7 +7,6 @@ import (
"github.com/photoprism/photoprism/internal/customize"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/fs"
)
@ -76,7 +75,7 @@ func (c *Config) SessionSettings(sess *entity.Session) *customize.Settings {
}
if sess.NoUser() && sess.IsClient() {
return c.Settings().ApplyACL(acl.Resources, acl.RoleClient).ApplyScope(sess.Scope())
return c.Settings().ApplyACL(acl.Resources, sess.ClientRole()).ApplyScope(sess.Scope())
}
user := sess.User()

View file

@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"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/authn"
@ -27,9 +28,10 @@ type Clients []Client
type Client struct {
ClientUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"ClientUID"`
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"`
user *User `gorm:"-"`
ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"`
ClientRole string `gorm:"size:64;default:'';" json:"ClientRole" yaml:"ClientRole,omitempty"`
ClientType string `gorm:"type:VARBINARY(16)" json:"ClientType" yaml:"ClientType,omitempty"`
ClientURL string `gorm:"type:VARBINARY(255);default:'';column:client_url;" json:"ClientURL" yaml:"ClientURL,omitempty"`
CallbackURL string `gorm:"type:VARBINARY(255);default:'';column:callback_url;" json:"CallbackURL" yaml:"CallbackURL,omitempty"`
@ -54,6 +56,7 @@ func NewClient() *Client {
return &Client{
UserUID: "",
ClientName: "",
ClientRole: acl.RoleClient.String(),
ClientType: authn.ClientConfidential,
ClientURL: "",
CallbackURL: "",
@ -77,8 +80,8 @@ func (m *Client) BeforeCreate(scope *gorm.Scope) error {
return scope.SetColumn("ClientUID", m.ClientUID)
}
// FindClient returns the matching client or nil if it was not found.
func FindClient(uid string) *Client {
// FindClientByUID returns the matching client or nil if it was not found.
func FindClientByUID(uid string) *Client {
if rnd.InvalidUID(uid, ClientUID) {
return nil
}
@ -103,6 +106,36 @@ func (m *Client) HasUID() bool {
return rnd.IsUID(m.ClientUID, ClientUID)
}
// Name returns the client name string.
func (m *Client) Name() string {
return m.ClientName
}
// SetRole sets the client role specified as string.
func (m *Client) SetRole(role string) *Client {
m.ClientRole = acl.ClientRoles[clean.Role(role)].String()
return m
}
// HasRole checks the client role specified as string.
func (m *Client) HasRole(role acl.Role) bool {
return m.AclRole() == role
}
// AclRole returns the client role for ACL permission checks.
func (m *Client) AclRole() acl.Role {
if m == nil {
return acl.RoleNone
}
if role, ok := acl.ClientRoles[clean.Role(m.ClientRole)]; ok {
return role
}
return acl.RoleNone
}
// User returns the related user account, if any.
func (m *Client) User() *User {
if m.user != nil {
@ -133,6 +166,17 @@ func (m *Client) SetUser(u *User) *Client {
return m
}
// UserInfo returns user identification info.
func (m *Client) UserInfo() string {
if m.UserUID == "" {
return ""
} else if m.UserName != "" {
return m.UserName
}
return m.UserUID
}
// Create new entity in the database.
func (m *Client) Create() error {
return Db().Create(m).Error
@ -257,14 +301,7 @@ func (m *Client) UpdateLastActive() *Client {
// NewSession creates a new client session.
func (m *Client) NewSession(c *gin.Context) *Session {
// Create, initialize, and return new session.
sess := NewSession(m.AuthExpires, 0).SetContext(c)
sess.AuthID = m.UID()
sess.AuthProvider = authn.ProviderClient.String()
sess.AuthMethod = m.Method().String()
sess.AuthScope = m.Scope()
sess.SetUser(m.User())
return sess
return NewSession(m.AuthExpires, 0).SetContext(c).SetClient(m)
}
// EnforceAuthTokenLimit deletes client sessions above the configured limit and returns the number of deleted sessions.
@ -323,6 +360,10 @@ func (m *Client) SetFormValues(frm form.Client) *Client {
m.ClientName = frm.Name()
}
if frm.ClientRole != "" {
m.SetRole(frm.ClientRole)
}
if frm.AuthMethod != "" {
m.AuthMethod = frm.Method().String()
}

View file

@ -2,23 +2,22 @@ package entity
import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// NewClientAccessToken returns a new access token session instance
// that can be used to access the API with unregistered clients.
func NewClientAccessToken(name string, lifetime int64, scope string, user *User) *Session {
func NewClientAccessToken(clientName string, lifetime int64, scope string, user *User) *Session {
sess := NewSession(lifetime, 0)
if name == "" {
name = rnd.Name()
if clientName == "" {
clientName = rnd.Name()
}
sess.AuthID = clean.Name(name)
sess.AuthProvider = authn.ProviderClient.String()
sess.AuthMethod = authn.MethodAccessToken.String()
sess.AuthScope = clean.Scope(scope)
sess.SetClientName(clientName)
sess.SetProvider(authn.ProviderClient)
sess.SetMethod(authn.MethodAccessToken)
sess.SetScope(scope)
if user != nil {
sess.SetUser(user)
@ -30,8 +29,8 @@ func NewClientAccessToken(name string, lifetime int64, scope string, user *User)
// CreateClientAccessToken initializes and creates a new access token session
// that can be used to access the API with unregistered clients.
func CreateClientAccessToken(name string, lifetime int64, scope string, user *User) (*Session, error) {
sess := NewClientAccessToken(name, lifetime, scope, user)
func CreateClientAccessToken(clientName string, lifetime int64, scope string, user *User) (*Session, error) {
sess := NewClientAccessToken(clientName, lifetime, scope, user)
if err := sess.Create(); err != nil {
return nil, err

View file

@ -1,6 +1,9 @@
package entity
import "github.com/photoprism/photoprism/pkg/authn"
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/authn"
)
type ClientMap map[string]Client
@ -27,6 +30,7 @@ var ClientFixtures = ClientMap{
UserName: UserFixtures.Pointer("alice").UserName,
user: UserFixtures.Pointer("alice"),
ClientName: "Alice",
ClientRole: acl.RoleClient.String(),
ClientType: authn.ClientConfidential,
ClientURL: "",
CallbackURL: "",
@ -43,6 +47,7 @@ var ClientFixtures = ClientMap{
UserName: UserFixtures.Pointer("bob").UserName,
user: UserFixtures.Pointer("bob"),
ClientName: "Bob",
ClientRole: acl.RoleClient.String(),
ClientType: authn.ClientPublic,
ClientURL: "",
CallbackURL: "",
@ -59,6 +64,7 @@ var ClientFixtures = ClientMap{
UserName: "",
user: nil,
ClientName: "Monitoring",
ClientRole: acl.RoleClient.String(),
ClientType: authn.ClientConfidential,
ClientURL: "",
CallbackURL: "",
@ -75,6 +81,7 @@ var ClientFixtures = ClientMap{
UserName: "",
user: nil,
ClientName: "Unknown",
ClientRole: acl.RoleNone.String(),
ClientType: authn.ClientUnknown,
ClientURL: "",
CallbackURL: "",
@ -91,6 +98,7 @@ var ClientFixtures = ClientMap{
UserName: "",
user: nil,
ClientName: "Deleted Monitoring",
ClientRole: acl.RoleClient.String(),
ClientType: authn.ClientConfidential,
ClientURL: "",
CallbackURL: "",

View file

@ -22,7 +22,7 @@ func TestFindClient(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
expected := ClientFixtures.Get("alice")
m := FindClient("cs5gfen1bgxz7s9i")
m := FindClientByUID("cs5gfen1bgxz7s9i")
if m == nil {
t.Fatal("result should not be nil")
@ -36,7 +36,7 @@ func TestFindClient(t *testing.T) {
t.Run("Bob", func(t *testing.T) {
expected := ClientFixtures.Get("bob")
m := FindClient("cs5gfsvbd7ejzn8m")
m := FindClientByUID("cs5gfsvbd7ejzn8m")
if m == nil {
t.Fatal("result should not be nil")
@ -50,7 +50,7 @@ func TestFindClient(t *testing.T) {
t.Run("Metrics", func(t *testing.T) {
expected := ClientFixtures.Get("metrics")
m := FindClient("cs5cpu17n6gj2qo5")
m := FindClientByUID("cs5cpu17n6gj2qo5")
if m == nil {
t.Fatal("result should not be nil")
@ -62,7 +62,7 @@ func TestFindClient(t *testing.T) {
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Invalid", func(t *testing.T) {
m := FindClient("123")
m := FindClientByUID("123")
assert.Nil(t, m)
})
}
@ -161,7 +161,7 @@ func TestClient_Create(t *testing.T) {
func TestClient_Save(t *testing.T) {
t.Run("Success", func(t *testing.T) {
c := FindClient("cs5cpu17n6gj2aaa")
c := FindClientByUID("cs5cpu17n6gj2aaa")
assert.Nil(t, c)
var m = Client{ClientName: "New Client", ClientUID: "cs5cpu17n6gj2aaa"}
@ -169,7 +169,7 @@ func TestClient_Save(t *testing.T) {
t.Fatal(err)
}
c = FindClient("cs5cpu17n6gj2aaa")
c = FindClientByUID("cs5cpu17n6gj2aaa")
if c == nil {
t.Fatal("result should not be nil")
@ -301,7 +301,7 @@ func TestClient_HasPassword(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
expected := ClientFixtures.Get("alice")
m := FindClient("cs5gfen1bgxz7s9i")
m := FindClientByUID("cs5gfen1bgxz7s9i")
if m == nil {
t.Fatal("result should not be nil")
@ -320,7 +320,7 @@ func TestClient_HasPassword(t *testing.T) {
t.Run("Metrics", func(t *testing.T) {
expected := ClientFixtures.Get("metrics")
m := FindClient("cs5cpu17n6gj2qo5")
m := FindClientByUID("cs5cpu17n6gj2qo5")
if m == nil {
t.Fatal("result should not be nil")

View file

@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/authn"
@ -32,11 +33,14 @@ type Sessions []Session
// Session represents a User session.
type Session struct {
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:"-"`
authToken string `gorm:"-"`
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"`
user *User `gorm:"-"`
ClientUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"ClientUID" yaml:"ClientUID,omitempty"`
ClientName string `gorm:"size:200;default:'';" json:"ClientName" yaml:"ClientName,omitempty"`
ClientIP string `gorm:"size:64;column:client_ip;index" json:"ClientIP" yaml:"ClientIP,omitempty"`
client *Client `gorm:"-"`
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"`
@ -121,7 +125,7 @@ func DeleteClientSessions(clientUID string, authMethod authn.MethodType, limit i
found := Sessions{}
q := Db().Where("auth_id = ? AND auth_provider = ?", clientUID, authn.ProviderClient.String())
q := Db().Where("client_uid = ?", clientUID)
if !authMethod.IsDefault() {
q = q.Where("auth_method = ?", authMethod.String())
@ -278,9 +282,100 @@ func (m *Session) BeforeCreate(scope *gorm.Scope) error {
return scope.SetColumn("ID", m.ID)
}
// User returns the session's user.
// SetClient updates the client of this session.
func (m *Session) SetClient(c *Client) *Session {
if c == nil {
return m
}
m.client = c
m.ClientUID = c.UID()
m.ClientName = c.ClientName
m.AuthProvider = authn.ProviderClient.String()
m.AuthMethod = c.Method().String()
m.AuthScope = c.Scope()
m.SetUser(c.User())
return m
}
// SetClientName changes the session's client name.
func (m *Session) SetClientName(s string) *Session {
if s == "" {
return m
}
m.ClientName = clean.Name(s)
return m
}
// Client returns the session's client.
func (m *Session) Client() *Client {
if m == nil {
return &Client{}
} else if m.client != nil {
return m.client
} else if c := FindClientByUID(m.ClientUID); c != nil {
m.SetClient(c)
return m.client
}
return &Client{
UserUID: m.UserUID,
UserName: m.UserName,
ClientUID: m.ClientUID,
ClientName: m.ClientName,
ClientRole: m.ClientRole().String(),
AuthScope: m.Scope(),
AuthMethod: m.AuthMethod,
}
}
// ClientRole returns the session's client ACL role.
func (m *Session) ClientRole() acl.Role {
if m.HasClient() {
return m.Client().AclRole()
} else if m.IsClient() {
return acl.RoleClient
}
return acl.RoleNone
}
// ClientInfo returns the session's client identifier string.
func (m *Session) ClientInfo() string {
if m.HasClient() {
return m.Client().Name()
}
return m.ClientName
}
// HasClient checks if a client entity is assigned to the session.
func (m *Session) HasClient() bool {
if m == nil {
return false
}
return m.ClientUID != ""
}
// NoClient if this session has no client entity assigned.
func (m *Session) NoClient() bool {
return !m.HasClient()
}
// IsClient checks if this session authenticates an API client.
func (m *Session) IsClient() bool {
return authn.Provider(m.AuthProvider).IsClient()
}
// User returns the session's user entity.
func (m *Session) User() *User {
if m.user != nil {
if m == nil {
return &User{}
} else if m.user != nil {
return m.user
} else if m.UserUID == "" {
return &User{}
@ -294,6 +389,54 @@ func (m *Session) User() *User {
return &User{}
}
// UserRole returns the session's user ACL role.
func (m *Session) UserRole() acl.Role {
return m.User().AclRole()
}
// UserInfo returns the session's user information.
func (m *Session) UserInfo() string {
name := m.Username()
if name != "" {
return name
}
return m.UserRole().String()
}
// SetUser updates the user entity of this session.
func (m *Session) SetUser(u *User) *Session {
if u == nil {
return m
}
// Update user.
m.user = u
m.UserUID = u.UserUID
m.UserName = u.UserName
// Update tokens.
m.SetPreviewToken(u.PreviewToken)
m.SetDownloadToken(u.DownloadToken)
return m
}
// HasUser checks if a user entity is assigned to the session.
func (m *Session) HasUser() bool {
if m == nil {
return false
}
return m.UserUID != ""
}
// NoUser checks if this session has no user entity assigned.
func (m *Session) NoUser() bool {
return !m.HasUser()
}
// RefreshUser updates the cached user data.
func (m *Session) RefreshUser() *Session {
// Remove user if uid is nil.
@ -310,24 +453,6 @@ func (m *Session) RefreshUser() *Session {
return m
}
// SetUser updates the session's user.
func (m *Session) SetUser(u *User) *Session {
if u == nil {
return m
}
// Update user.
m.user = u
m.UserUID = u.UserUID
m.UserName = u.UserName
// Update tokens.
m.SetPreviewToken(u.PreviewToken)
m.SetDownloadToken(u.DownloadToken)
return m
}
// Username returns the login name.
func (m *Session) Username() string {
return m.UserName
@ -409,11 +534,6 @@ func (m *Session) SetProvider(provider authn.ProviderType) *Session {
return m
}
// IsClient checks whether this session is used to authenticate an API client.
func (m *Session) IsClient() bool {
return authn.Provider(m.AuthProvider).IsClient()
}
// ChangePassword changes the password of the current user.
func (m *Session) ChangePassword(newPw string) (err error) {
u := m.User()
@ -605,20 +725,6 @@ func (m *Session) HasShares() bool {
}
}
// NoUser checks if this session has no specific user assigned.
func (m *Session) NoUser() bool {
return !m.HasUser()
}
// HasUser checks if a user account is assigned to the session.
func (m *Session) HasUser() bool {
if m == nil {
return false
}
return m.UserUID != ""
}
// HasRegisteredUser checks if the session belongs to a registered user.
func (m *Session) HasRegisteredUser() bool {
if !m.HasUser() {

View file

@ -44,7 +44,7 @@ var SessionFixtures = SessionMap{
AuthScope: clean.Scope("*"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodAccessToken.String(),
AuthID: "alice_token",
ClientName: "alice_token",
LastActive: -1,
user: UserFixtures.Pointer("alice"),
UserUID: UserFixtures.Pointer("alice").UserUID,
@ -59,7 +59,7 @@ var SessionFixtures = SessionMap{
AuthScope: clean.Scope("*"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodAccessToken.String(),
AuthID: "alice_token_personal",
ClientName: "alice_token_personal",
LastActive: -1,
user: UserFixtures.Pointer("alice"),
UserUID: UserFixtures.Pointer("alice").UserUID,
@ -74,7 +74,7 @@ var SessionFixtures = SessionMap{
AuthScope: clean.Scope("webdav"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodAccessToken.String(),
AuthID: "alice_token_webdav",
ClientName: "alice_token_webdav",
LastActive: -1,
user: UserFixtures.Pointer("alice"),
UserUID: UserFixtures.Pointer("alice").UserUID,
@ -89,7 +89,7 @@ var SessionFixtures = SessionMap{
AuthScope: clean.Scope("metrics photos albums videos"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodAccessToken.String(),
AuthID: "alice_token_scope",
ClientName: "alice_token_scope",
user: UserFixtures.Pointer("alice"),
UserUID: UserFixtures.Pointer("alice").UserUID,
UserName: UserFixtures.Pointer("alice").UserName,
@ -140,7 +140,7 @@ var SessionFixtures = SessionMap{
AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodAccessToken.String(),
AuthID: "visitor_token_metrics",
ClientName: "visitor_token_metrics",
user: &Visitor,
UserUID: Visitor.UserUID,
UserName: Visitor.UserName,
@ -155,6 +155,23 @@ var SessionFixtures = SessionMap{
UserUID: UserFixtures.Pointer("friend").UserUID,
UserName: UserFixtures.Pointer("friend").UserName,
},
"client_metrics": {
authToken: "9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212345",
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212345"),
RefID: "sessgh612345",
SessTimeout: 0,
SessExpires: UnixTime() + UnixWeek,
AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodOAuth2.String(),
ClientUID: ClientFixtures.Get("metrics").ClientUID,
ClientName: ClientFixtures.Get("metrics").ClientName,
user: nil,
UserUID: "",
UserName: "",
PreviewToken: "py212345",
DownloadToken: "vgl12345",
},
"token_metrics": {
authToken: "9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b",
ID: rnd.SessionID("9d8b8801ffa23eb52e08ca7766283799ddfd8dd368208a9b"),
@ -164,7 +181,7 @@ var SessionFixtures = SessionMap{
AuthScope: clean.Scope("metrics"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodAccessToken.String(),
AuthID: "token_metrics",
ClientName: "token_metrics",
user: nil,
UserUID: "",
UserName: "",
@ -180,7 +197,7 @@ var SessionFixtures = SessionMap{
AuthScope: clean.Scope("settings"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodAccessToken.String(),
AuthID: "token_settings",
ClientName: "token_settings",
user: nil,
UserUID: "",
UserName: "",

View file

@ -125,7 +125,8 @@ func AuthLocal(user *User, f form.Login, m *Session, c *gin.Context) (authn.Prov
m.Status = http.StatusUnauthorized
return authn.ProviderNone, i18n.Error(i18n.ErrInvalidCredentials)
} else {
m.SetAuthID(authSess.AuthID)
m.ClientUID = authSess.ClientUID
m.ClientName = authSess.ClientName
m.SetScope(authSess.Scope())
m.SetMethod(authn.MethodSession)
event.AuditInfo([]string{clientIp, "session %s", "login as %s with auth secret", "succeeded"}, m.RefID, clean.LogQuote(userName))

View file

@ -5,11 +5,11 @@ import (
"testing"
"time"
"github.com/photoprism/photoprism/pkg/header"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/rnd"
)
@ -99,22 +99,23 @@ func TestDeleteExpiredSessions(t *testing.T) {
}
func TestDeleteClientSessions(t *testing.T) {
// Test client UID.
clientUID := "cs5gfen1bgx00000"
// Create new test client.
client := NewClient()
client.ClientUID = "cs5gfen1bgx00000"
// Make sure no sessions exist yet and test missing arguments.
assert.Equal(t, 0, DeleteClientSessions("", "", -1))
assert.Equal(t, 0, DeleteClientSessions(clientUID, authn.MethodOAuth2, -1))
assert.Equal(t, 0, DeleteClientSessions(clientUID, authn.MethodOAuth2, 0))
assert.Equal(t, 0, DeleteClientSessions("", authn.MethodDefault, 0))
// Create 10 client sessions.
// Create 10 test client sessions.
for i := 0; i < 10; i++ {
sess := NewSession(3600, 0)
sess.SetClientIP(UnknownIP)
sess.AuthID = clientUID
sess.AuthProvider = authn.ProviderClient.String()
sess.AuthMethod = authn.MethodOAuth2.String()
sess.AuthScope = "*"
sess.SetClient(client)
if err := sess.Save(); err != nil {
t.Fatal(err)
@ -313,24 +314,140 @@ func TestSession_Updates(t *testing.T) {
m := FindSessionByRefID("sessxkkcabcd")
assert.Equal(t, "alice", m.UserName)
m.Updates(Session{UserName: "anton"})
if err := m.Updates(Session{UserName: "anton"}); err != nil {
t.Fatal(err)
}
assert.Equal(t, "anton", m.UserName)
}
func TestSession_Client(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
m := FindSessionByRefID("sessxkkcabcd")
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.User().UserUID)
assert.Equal(t, "", m.Client().ClientUID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.Client().UserUID)
assert.Equal(t, acl.RoleNone, m.Client().AclRole())
assert.Equal(t, acl.RoleNone, m.ClientRole())
})
t.Run("AliceTokenPersonal", func(t *testing.T) {
m := SessionFixtures.Get("alice_token_personal")
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.User().UserUID)
assert.Equal(t, "", m.Client().ClientUID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.Client().UserUID)
assert.Equal(t, acl.RoleClient, m.Client().AclRole())
assert.Equal(t, acl.RoleClient, m.ClientRole())
})
t.Run("ClientMetrics", func(t *testing.T) {
m := SessionFixtures.Get("client_metrics")
assert.Equal(t, "", m.UserUID)
assert.Equal(t, "", m.User().UserUID)
assert.Equal(t, "cs5cpu17n6gj2qo5", m.Client().ClientUID)
assert.Equal(t, "", m.Client().UserUID)
assert.Equal(t, acl.RoleClient, m.Client().AclRole())
assert.Equal(t, acl.RoleClient, m.ClientRole())
})
t.Run("Default", func(t *testing.T) {
m := &Session{}
assert.Equal(t, "", m.UserUID)
assert.Equal(t, "", m.User().UserUID)
assert.Equal(t, "", m.Client().ClientUID)
assert.Equal(t, "", m.Client().UserUID)
assert.Equal(t, acl.RoleNone, m.Client().AclRole())
assert.Equal(t, acl.RoleNone, m.ClientRole())
})
}
func TestSession_ClientRole(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
m := SessionFixtures.Get("alice")
assert.Equal(t, acl.RoleNone, m.ClientRole())
})
t.Run("AliceTokenPersonal", func(t *testing.T) {
m := SessionFixtures.Get("alice_token_personal")
assert.Equal(t, acl.RoleClient, m.ClientRole())
})
t.Run("TokenMetrics", func(t *testing.T) {
m := SessionFixtures.Get("token_metrics")
assert.Equal(t, acl.RoleClient, m.ClientRole())
})
t.Run("TokenSettings", func(t *testing.T) {
m := SessionFixtures.Get("token_settings")
assert.Equal(t, acl.RoleClient, m.ClientRole())
})
t.Run("Default", func(t *testing.T) {
m := &Session{}
assert.Equal(t, acl.RoleNone, m.ClientRole())
})
}
func TestSession_SetClient(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
m := SessionFixtures.Get("alice")
assert.Equal(t, acl.RoleNone, m.ClientRole())
assert.Equal(t, "", m.Client().ClientUID)
m.SetClient(ClientFixtures.Pointer("alice"))
assert.Equal(t, acl.RoleClient, m.ClientRole())
assert.Equal(t, "cs5gfen1bgxz7s9i", m.Client().ClientUID)
})
}
func TestSession_SetClientName(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
m := SessionFixtures.Get("alice_token_personal")
assert.Equal(t, "", m.ClientUID)
assert.Equal(t, "alice_token_personal", m.ClientName)
assert.Equal(t, "alice_token_personal", m.ClientInfo())
m.SetClientName("Foo Bar!")
assert.Equal(t, "", m.ClientUID)
assert.Equal(t, "Foo Bar!", m.ClientName)
assert.Equal(t, "Foo Bar!", m.ClientInfo())
m.SetClientName("")
assert.Equal(t, "Foo Bar!", m.ClientName)
assert.Equal(t, "Foo Bar!", m.ClientInfo())
})
t.Run("setNewID", func(t *testing.T) {
m := NewSession(0, 0)
assert.Equal(t, "", m.ClientUID)
assert.Equal(t, "", m.ClientName)
assert.Equal(t, "", m.ClientInfo())
m.SetClientName("Foo Bar!")
assert.Equal(t, "", m.ClientUID)
assert.Equal(t, "Foo Bar!", m.ClientName)
assert.Equal(t, "Foo Bar!", m.ClientInfo())
})
}
func TestSession_User(t *testing.T) {
t.Run("alice", func(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
m := FindSessionByRefID("sessxkkcabcd")
assert.Equal(t, "uqxetse3cy5eo9z2", m.User().UserUID)
})
t.Run("empty", func(t *testing.T) {
t.Run("Default", func(t *testing.T) {
m := &Session{}
assert.Equal(t, "", m.User().UserUID)
})
}
func TestSession_UserRole(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
m := FindSessionByRefID("sessxkkcabcd")
assert.Equal(t, acl.RoleAdmin, m.UserRole())
})
t.Run("Bob", func(t *testing.T) {
m := FindSessionByRefID("sessxkkcabce")
assert.Equal(t, acl.RoleAdmin, m.UserRole())
})
t.Run("Default", func(t *testing.T) {
m := &Session{}
assert.Equal(t, acl.RoleNone, m.UserRole())
})
}
func TestSession_RefreshUser(t *testing.T) {
t.Run("bob", func(t *testing.T) {
t.Run("Bob", func(t *testing.T) {
m := FindSessionByRefID("sessxkkcabce")
assert.Equal(t, "bob", m.Username())
@ -367,7 +484,7 @@ func TestSession_AuthInfo(t *testing.T) {
}
func TestSession_SetAuthID(t *testing.T) {
t.Run("emptyID", func(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
s := &Session{
UserName: "test",
RefID: "sessxkkcxxxz",
@ -378,7 +495,7 @@ func TestSession_SetAuthID(t *testing.T) {
assert.Equal(t, "test-session-auth-id", m.AuthID)
})
t.Run("setNewID", func(t *testing.T) {
t.Run("New", func(t *testing.T) {
s := &Session{
UserName: "test",
RefID: "sessxkkcxxxz",
@ -392,7 +509,7 @@ func TestSession_SetAuthID(t *testing.T) {
}
func TestSession_SetScope(t *testing.T) {
t.Run("emptyScope", func(t *testing.T) {
t.Run("EmptyScope", func(t *testing.T) {
s := &Session{
UserName: "test",
RefID: "sessxkkcxxxz",
@ -403,7 +520,7 @@ func TestSession_SetScope(t *testing.T) {
assert.Equal(t, "*", m.AuthScope)
})
t.Run("setNewScope", func(t *testing.T) {
t.Run("NewScope", func(t *testing.T) {
s := &Session{
UserName: "test",
RefID: "sessxkkcxxxz",
@ -417,7 +534,7 @@ func TestSession_SetScope(t *testing.T) {
}
func TestSession_SetMethod(t *testing.T) {
t.Run("emptyMethod", func(t *testing.T) {
t.Run("EmptyMethod", func(t *testing.T) {
s := &Session{
UserName: "test",
RefID: "sessxkkcxxxz",
@ -428,7 +545,7 @@ func TestSession_SetMethod(t *testing.T) {
assert.Equal(t, "Access Token", m.AuthMethod)
})
t.Run("setNewMethod", func(t *testing.T) {
t.Run("NewMethod", func(t *testing.T) {
s := &Session{
UserName: "test",
RefID: "sessxkkcxxxz",

View file

@ -48,7 +48,7 @@ type User struct {
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;unique_index;" json:"UID" yaml:"UID"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
UserName string `gorm:"size:255;index;" json:"Name" yaml:"Name,omitempty"`
UserName string `gorm:"size:200;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"`
@ -627,9 +627,9 @@ func (m *User) SetRole(role string) *User {
switch role {
case "", "0", "false", "nil", "null", "nan":
m.UserRole = acl.RoleUnknown.String()
m.UserRole = acl.RoleNone.String()
default:
m.UserRole = acl.ValidRoles[role].String()
m.UserRole = acl.UserRoles[role].String()
}
return m
@ -648,11 +648,11 @@ func (m *User) AclRole() acl.Role {
case m.SuperAdmin:
return acl.RoleAdmin
case role == "":
return acl.RoleUnknown
return acl.RoleNone
case m.UserName == "":
return acl.RoleVisitor
default:
return acl.ValidRoles[role]
return acl.UserRoles[role]
}
}
@ -754,7 +754,7 @@ func (m *User) IsUnknown() bool {
return true
}
return !rnd.IsUID(m.UserUID, UserUID) || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID || m.HasRole(acl.RoleUnknown)
return !rnd.IsUID(m.UserUID, UserUID) || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID || m.HasRole(acl.RoleNone)
}
// DeleteSessions deletes all active user sessions except those passed as argument.
@ -867,7 +867,7 @@ func (m *User) Validate() (err error) {
}
// Check user role.
if acl.ValidRoles[m.UserRole] == "" {
if acl.UserRoles[m.UserRole] == "" {
return fmt.Errorf("user role %s is invalid", clean.LogQuote(m.UserRole))
}

View file

@ -36,7 +36,7 @@ var UnknownUser = User{
UserUID: "u000000000000001",
UserName: "",
AuthProvider: authn.ProviderNone.String(),
UserRole: acl.RoleUnknown.String(),
UserRole: acl.RoleNone.String(),
CanLogin: false,
WebDAV: false,
CanInvite: false,

View file

@ -128,7 +128,7 @@ var UserFixtures = UserMap{
UserUID: "uriku0138hqql4bz",
UserName: "jens.mander",
UserEmail: "jens.mander@microsoft.com",
UserRole: acl.RoleUnknown.String(),
UserRole: acl.RoleNone.String(),
AuthProvider: authn.ProviderNone.String(),
SuperAdmin: false,
DisplayName: "Jens Mander",

View file

@ -39,7 +39,7 @@ func TestUserMap_Pointer(t *testing.T) {
r := UserFixtures.Pointer("monstera")
assert.Equal(t, "", r.UserName)
assert.Equal(t, "", r.Email())
assert.Equal(t, acl.RoleUnknown, r.AclRole())
assert.Equal(t, acl.RoleNone, r.AclRole())
assert.IsType(t, &User{}, r)
})
}

View file

@ -739,7 +739,7 @@ func TestUser_AclRole(t *testing.T) {
})
t.Run("Unauthorized", func(t *testing.T) {
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
assert.Equal(t, acl.RoleUnknown, p.AclRole())
assert.Equal(t, acl.RoleNone, p.AclRole())
assert.False(t, p.IsAdmin())
assert.False(t, p.IsVisitor())
})
@ -943,7 +943,7 @@ func TestDeleteUser(t *testing.T) {
UserName: "thomasdel2",
UserEmail: "thomasdel2@example.com",
DisplayName: "Thomas Delete 2",
UserRole: acl.RoleUnknown.String(),
UserRole: acl.RoleNone.String(),
}
err := u.Delete()

View file

@ -13,6 +13,7 @@ type Client struct {
UserUID string `json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
ClientName string `json:"ClientName,omitempty" yaml:"ClientName,omitempty"`
ClientRole string `json:"ClientRole,omitempty" yaml:"ClientRole,omitempty"`
AuthMethod string `json:"AuthMethod,omitempty" yaml:"AuthMethod,omitempty"`
AuthScope string `json:"AuthScope,omitempty" yaml:"AuthScope,omitempty"`
AuthExpires int64 `json:"AuthExpires,omitempty" yaml:"AuthExpires,omitempty"`
@ -39,6 +40,7 @@ func NewClientFromCli(ctx *cli.Context) Client {
f := NewClient()
f.ClientName = clean.Name(ctx.String("name"))
f.ClientRole = clean.Name(ctx.String("role"))
f.AuthScope = clean.Scope(ctx.String("scope"))
f.AuthMethod = authn.Method(ctx.String("method")).String()
@ -60,6 +62,11 @@ func (f *Client) Name() string {
return clean.Name(f.ClientName)
}
// Role returns the sanitized client role.
func (f *Client) Role() string {
return clean.Role(f.ClientRole)
}
// Method returns the sanitized auth method name.
func (f *Client) Method() authn.MethodType {
return authn.Method(f.AuthMethod)

View file

@ -103,25 +103,25 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
} else if sess.IsClient() && !sess.HasScope(acl.ResourceWebDAV.String()) {
// Log error if the client is allowed to access webdav based on its scope.
message := "denied"
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.AuthID), sess.RefID, clean.LogQuote(user.Username()))
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), sess.RefID, clean.LogQuote(user.Username()))
WebDAVAbortUnauthorized(c)
return
} else if !user.CanUseWebDAV() {
// Log warning if WebDAV is disabled for this account.
message := "webdav access is disabled"
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.AuthID), sess.RefID, clean.LogQuote(user.Username()))
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), sess.RefID, clean.LogQuote(user.Username()))
WebDAVAbortUnauthorized(c)
return
} else if username != "" && !strings.EqualFold(clean.Username(username), user.Username()) {
// Log warning if WebDAV is disabled for this account.
message := "basic auth username does not match"
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.AuthID), sess.RefID, clean.LogQuote(user.Username()))
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), sess.RefID, clean.LogQuote(user.Username()))
WebDAVAbortUnauthorized(c)
return
} else if err := os.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath()), fs.ModeDir); err != nil {
// Log warning if upload path could not be created.
message := "failed to create user upload path"
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.AuthID), sess.RefID, clean.LogQuote(user.Username()))
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), sess.RefID, clean.LogQuote(user.Username()))
WebDAVAbortServerError(c)
return
} else {