Auth: Refactor user management API and CLI commands #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-03-08 23:30:39 +01:00
parent e19697bd98
commit 60162b3fc5
54 changed files with 818 additions and 327 deletions

View file

@ -33,7 +33,8 @@ import Labels from "page/labels.vue";
import People from "page/people.vue";
import Library from "page/library.vue";
import Settings from "page/settings.vue";
import Login from "page/login.vue";
import Admin from "page/admin.vue";
import Login from "page/auth/login.vue";
import Discover from "page/discover.vue";
import About from "page/about/about.vue";
import Feedback from "page/about/feedback.vue";
@ -91,6 +92,18 @@ export default [
}
},
},
{
name: "admin",
path: "/admin/*",
component: Admin,
meta: {
title: $gettext("Settings"),
auth: true,
admin: true,
settings: true,
background: "application-light",
},
},
{
name: "upgrade",
path: "/upgrade",

View file

@ -476,6 +476,14 @@
</v-list-tile-content>
</v-list-tile>
<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>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-show="!isPublic && isAdmin && isSponsor" :to="{ name: 'feedback' }" :exact="true" class="nav-feedback"
@click.stop="">
<v-list-tile-content>
@ -696,6 +704,7 @@ export default {
canAccessPrivate: !isRestricted && this.$config.allow("photos", "access_private"),
canManagePhotos: this.$config.allow("photos", "manage"),
canManagePeople: this.$config.allow("people", "manage"),
canManageUsers: this.$config.allow("users", "manage"),
appNameSuffix: appNameSuffix,
appName: this.$config.getName(),
appAbout: this.$config.getAbout(),

View file

@ -63,6 +63,14 @@ body.chrome #photoprism .search-results .result {
top: 12px;
}
#photoprism .list-view .action-secondary {
opacity: 0.4;
}
#photoprism .list-view tr:hover .action-secondary {
opacity: 1;
}
#photoprism .p-clipboard.--ltr {
right: 8px;
bottom: 12px;

View file

@ -14,10 +14,10 @@
</v-card-title>
<v-card-text class="py-0 px-2">
<v-layout wrap align-top>
<v-flex xs12 class="px-2 pb-2 caption">
<v-flex v-if="oldRequired" xs12 class="px-2 pb-2 caption">
<translate>Please note that changing your password will log you out on other devices and browsers.</translate>
</v-flex>
<v-flex xs12 class="px-2 py-1">
<v-flex v-if="oldRequired" xs12 class="px-2 py-1">
<v-text-field
v-model="oldPassword"
hide-details required box flat
@ -88,10 +88,16 @@
</v-dialog>
</template>
<script>
import User from "../../model/user";
export default {
name: 'PAccountPasswordDialog',
props: {
show: Boolean,
model: {
type: Object,
default: () => new User(null),
},
},
data() {
return {
@ -105,7 +111,17 @@ export default {
rtl: this.$rtl,
};
},
computed: {},
computed: {
oldRequired() {
if (!this.model) {
return true;
}
const sessionUser = this.$session.getUser();
return !sessionUser.SuperAdmin || this.model.getId() === sessionUser.getId();
},
},
created() {
if(this.isPublic && !this.isDemo) {
this.$emit('cancel');
@ -113,11 +129,11 @@ export default {
},
methods: {
disabled() {
return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < this.passwordLength || (this.newPassword !== this.confirmPassword));
return (this.isDemo || this.busy || this.oldPassword === "" && this.oldRequired || this.newPassword.length < this.passwordLength || (this.newPassword !== this.confirmPassword));
},
confirm() {
this.busy = true;
this.$session.getUser().changePassword(this.oldPassword, this.newPassword).then(() => {
this.model.changePassword(this.oldPassword, this.newPassword).then(() => {
this.$notify.success(this.$gettext("Password changed"));
this.$emit('confirm');
}).finally(() => {

View file

@ -43,6 +43,7 @@ import PWebdavDialog from "dialog/webdav.vue";
import PReloadDialog from "dialog/reload.vue";
import PSponsorDialog from "dialog/sponsor.vue";
import PConfirmDialog from "dialog/confirm.vue";
import PAccountPasswordDialog from "dialog/account/password.vue";
const dialogs = {};
@ -67,6 +68,7 @@ dialogs.install = (Vue) => {
Vue.component("PReloadDialog", PReloadDialog);
Vue.component("PSponsorDialog", PSponsorDialog);
Vue.component("PConfirmDialog", PConfirmDialog);
Vue.component("PAccountPasswordDialog", PAccountPasswordDialog);
};
export default dialogs;

View file

@ -1,5 +1,5 @@
<template>
<v-dialog :value="show" lazy persistent max-width="500" class="p-account-create-dialog" @keydown.esc="cancel">
<v-dialog :value="show" lazy persistent max-width="500" class="p-account-add-dialog" @keydown.esc="cancel">
<v-card raised elevation="24">
<v-card-title primary-title class="pa-2">
<v-layout row wrap>
@ -76,7 +76,7 @@ import Service from "model/service";
import * as options from "options/options";
export default {
name: 'PAccountCreateDialog',
name: 'PAccountAddDialog',
props: {
show: Boolean,
},

View file

@ -31,7 +31,11 @@ import { $gettext } from "common/vm";
export class Rest extends Model {
getId() {
return this.UID ? this.UID : this.ID;
if (this.UID) {
return this.UID;
}
return this.ID ? this.ID : "";
}
hasId() {
@ -52,6 +56,16 @@ export class Rest extends Model {
);
}
load() {
if (!this.hasId()) {
return;
}
return Api.get(this.getEntityResource(this.getId())).then((resp) =>
Promise.resolve(this.setValues(resp.data))
);
}
save() {
if (this.hasId()) {
return this.update();
@ -72,7 +86,7 @@ export class Rest extends Model {
}
// Send PUT request.
return Api.put(this.getEntityResource(), this.getValues(true)).then((resp) =>
return Api.put(this.getEntityResource(), values).then((resp) =>
Promise.resolve(this.setValues(resp.data))
);
}

View file

@ -101,6 +101,7 @@ export class User extends RestModel {
CreatedAt: "",
UpdatedAt: "",
},
LoginAt: "",
VerifiedAt: "",
ConsentAt: "",
BornAt: "",
@ -184,6 +185,10 @@ export class User extends RestModel {
);
}
isLocal() {
return !this.AuthProvider || this.AuthProvider === "local";
}
changePassword(oldPassword, newPassword) {
return Api.put(this.getEntityResource() + "/password", {
old: oldPassword,
@ -191,12 +196,6 @@ export class User extends RestModel {
}).then((response) => Promise.resolve(response.data));
}
save() {
return Api.post(this.getEntityResource(), this.getValues()).then((response) =>
Promise.resolve(this.setValues(response.data))
);
}
static getCollectionResource() {
return "users";
}

View file

@ -0,0 +1,18 @@
<template>
<p-page-about></p-page-about>
</template>
<script>
import PPageAbout from "./about/about.vue";
export default {
name: 'PPageAdmin',
components: {PPageAbout},
data() {
return {
rtl: this.$rtl,
};
},
methods: {},
};
</script>

View file

@ -50,7 +50,7 @@ import tabColors from "page/discover/colors.vue";
import tabTodo from "page/discover/todo.vue";
export default {
name: 'PPageSettings',
name: 'PPageDiscover',
components: {
'p-tab-discover-colors': tabColors,
'p-tab-discover-todo': tabTodo,

View file

@ -2,7 +2,7 @@
<div class="p-tab p-settings-account">
<v-form ref="form" v-model="valid" lazy-validation dense class="p-form-account pb-4 width-lg" accept-charset="UTF-8"
@submit.prevent="onChange">
<input ref="upload" type="file" class="d-none input-upload" @change.stop="onUploadAvatar()">
<input ref="upload" type="file" class="d-none input-upload" accept="image/png, image/jpeg" @change.stop="onUploadAvatar()">
<v-card flat tile class="mt-2 px-1 application">
<v-card-actions>
<v-layout row wrap align-top>
@ -296,7 +296,7 @@
</v-card-actions>
</v-card>
</v-form>
<p-account-password-dialog :show="dialog.password" @cancel="dialog.password = false" @confirm="dialog.password = false"></p-account-password-dialog>
<p-account-password-dialog :show="dialog.password" :model="user" @cancel="dialog.password = false" @confirm="dialog.password = false"></p-account-password-dialog>
<p-webdav-dialog :show="dialog.webdav" @close="dialog.webdav = false"></p-webdav-dialog>
</div>
</template>

View file

@ -12,7 +12,6 @@
<v-card-actions>
<v-layout wrap align-top>
<v-flex xs12 sm4 class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.estimates"

View file

@ -6,7 +6,7 @@
:items="results"
hide-actions
disable-initial-sort
class="elevation-0 p-accounts p-accounts-list p-results"
class="elevation-0 account-results list-view"
item-key="ID"
:no-data-text="$gettext('No services configured.')"
>
@ -19,6 +19,7 @@
<td class="text-xs-center">
<v-btn icon small flat :ripple="false"
class="action-toggle-share"
color="transparent"
@click.stop.prevent="editSharing(props.item)">
<v-icon v-if="props.item.AccShare" color="secondary-dark">check</v-icon>
<v-icon v-else color="secondary-dark">settings</v-icon>
@ -27,6 +28,7 @@
<td class="text-xs-center">
<v-btn icon small flat :ripple="false"
class="action-toggle-sync"
color="transparent"
@click.stop.prevent="editSync(props.item)">
<v-icon v-if="props.item.AccErrors" color="secondary-dark" :title="props.item.AccError">report_problem
</v-icon>
@ -37,12 +39,14 @@
<td class="hidden-sm-and-down">{{ formatDate(props.item.SyncDate) }}</td>
<td class="hidden-xs-only text-xs-right" nowrap>
<v-btn icon small flat :ripple="false"
class="p-account-remove"
class="action-remove action-secondary"
color="transparent"
@click.stop.prevent="remove(props.item)">
<v-icon color="secondary-dark">delete</v-icon>
</v-btn>
<v-btn icon small flat :ripple="false"
class="p-account-remove"
class="action-edit"
color="transparent"
@click.stop.prevent="edit(props.item)">
<v-icon color="secondary-dark">edit</v-icon>
</v-btn>

2
go.mod
View file

@ -66,7 +66,7 @@ require (
golang.org/x/oauth2 v0.4.0 // indirect
)
require github.com/gabriel-vasile/mimetype v1.4.1
require github.com/gabriel-vasile/mimetype v1.4.2
require (
golang.org/x/sync v0.1.0

5
go.sum
View file

@ -129,8 +129,8 @@ github.com/esimov/pigo v1.4.6 h1:wpB9FstbqeGP/CZP+nTR52tUJe7XErq8buG+k4xCXlw=
github.com/esimov/pigo v1.4.6/go.mod h1:uqj9Y3+3IRYhFK071rxz1QYq0ePhA6+R9jrUZavi46M=
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@ -533,7 +533,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=

View file

@ -62,7 +62,7 @@ var Resources = ACL{
RoleAdmin: GrantFullAccess,
},
ResourceUsers: Roles{
RoleAdmin: GrantFullAccess,
RoleAdmin: Grant{AccessAll: true, AccessOwn: true, ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionSubscribe: true},
},
ResourceConfig: Roles{
RoleAdmin: GrantFullAccess,

View file

@ -6,15 +6,15 @@ import (
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/fs"
"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/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// UploadUserAvatar updates the avatar image of the currently authenticated user.
@ -35,15 +35,18 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return
}
// Check if the session user is has user management privileges.
isPrivileged := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
uid := clean.UID(c.Param("uid"))
// Users may only change their own avatar.
if s.User().UserUID != uid {
if !isPrivileged && s.User().UserUID != uid {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "user uid does not match"}, s.RefID)
AbortForbidden(c)
return
}
// Parse upload form.
f, err := c.MultipartForm()
if err != nil {
@ -52,6 +55,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return
}
// Check number of files.
files := f.File["files"]
if len(files) != 1 {
@ -59,7 +63,16 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return
}
uploadDir, err := conf.UserUploadPath(s.UserUID, "")
// Find user entity to update.
m := entity.FindUserByUID(uid)
if m == nil {
Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
return
}
// Get user upload folder.
uploadDir, err := conf.UserUploadPath(uid, "")
if err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to create folder", "%s"}, s.RefID, err)
@ -68,8 +81,9 @@ func UploadUserAvatar(router *gin.RouterGroup) {
}
file := files[0]
var fileName string
// Uploaded images must be JPEGs with a maximum file size of 20 MB.
// The user avatar must be a PNG or JPEG image with a maximum size of 20 MB.
if file.Size > 20000000 {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "file size exceeded"}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrFileTooLarge)
@ -82,15 +96,23 @@ func UploadUserAvatar(router *gin.RouterGroup) {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
} else if !mimeType.Is(fs.MimeTypeJPEG) {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "only jpeg supported"}, s.RefID)
} else {
switch {
case mimeType.Is(fs.MimeTypePNG):
fileName = "avatar.png"
case mimeType.Is(fs.MimeTypeJPEG):
fileName = "avatar.jpg"
default:
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", " %s not supported"}, s.RefID, mimeType)
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
return
}
}
fileName := "avatar.jpg"
// Get absolute file path.
filePath := path.Join(uploadDir, fileName)
// Save avatar image.
if err = c.SaveUploadedFile(file, filePath); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to save %s"}, s.RefID, clean.Log(filePath))
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
@ -99,21 +121,24 @@ func UploadUserAvatar(router *gin.RouterGroup) {
event.AuditInfo([]string{ClientIP(c), "session %s", "upload avatar", "saved as %s"}, s.RefID, clean.Log(filePath))
}
// Create avatar thumbnails.
if mediaFile, mediaErr := photoprism.NewMediaFile(filePath); mediaErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
return
} else if err = mediaFile.CreateThumbnails(conf.ThumbCachePath(), false); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
} else if err = s.User().SetAvatar(mediaFile.Hash(), entity.SrcManual); err != nil {
} else if err = m.SetAvatar(mediaFile.Hash(), entity.SrcManual); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
}
// Clear the session cache, as it contains user information.
// Clear session cache to update user details.
s.ClearCache()
// Show success message.
log.Info(i18n.Msg(i18n.MsgFileUploaded))
// Return updated user profile.
c.JSON(http.StatusOK, entity.FindUserByUID(uid))
})
}

View file

@ -7,6 +7,7 @@ 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/form"
"github.com/photoprism/photoprism/internal/get"
@ -41,15 +42,22 @@ func UpdateUserPassword(router *gin.RouterGroup) {
return
}
// Check if the session user is has user management privileges.
isPrivileged := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
isSuperAdmin := isPrivileged && s.User().IsSuperAdmin()
uid := clean.UID(c.Param("uid"))
var u *entity.User
// Users may only change their own password.
if s.User().UserUID != clean.UID(c.Param("uid")) {
if !isPrivileged && s.User().UserUID != uid {
AbortForbidden(c)
return
}
u := s.User()
if u == nil {
} else if s.User().UserUID == uid {
u = s.User()
isPrivileged = false
isSuperAdmin = false
} else if u = entity.FindUserByUID(uid); u == nil {
Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
return
}
@ -62,7 +70,9 @@ func UpdateUserPassword(router *gin.RouterGroup) {
}
// Verify that the old password is correct.
if u.WrongPassword(f.OldPassword) {
if isSuperAdmin && f.OldPassword == "" {
// Do nothing.
} else if u.WrongPassword(f.OldPassword) {
limiter.Login.Reserve(ClientIP(c))
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return

View file

@ -20,11 +20,12 @@ func UpdateUser(router *gin.RouterGroup) {
router.PUT("/users/:uid", func(c *gin.Context) {
conf := get.Config()
if conf.Demo() || conf.DisableSettings() {
if conf.Public() || conf.DisableSettings() {
AbortForbidden(c)
return
}
// Check if the session user is allowed to manage all accounts or update his/her own account.
s := AuthAny(c, acl.ResourceUsers, acl.Permissions{acl.ActionManage, acl.AccessOwn, acl.ActionUpdate})
if s.Abort(c) {
@ -56,8 +57,16 @@ func UpdateUser(router *gin.RouterGroup) {
return
}
// Check if the session user is has user management privileges.
isPrivileged := acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage})
// Prevent super admins from locking themselves out.
if u := s.User(); u.IsSuperAdmin() && u.Equal(m) && !f.CanLogin {
f.CanLogin = true
}
// Save model with values from form.
if err = m.SaveForm(f); err != nil {
if err = m.SaveForm(f, isPrivileged); err != nil {
log.Error(err)
AbortSaveFailed(c)
return

View file

@ -31,6 +31,6 @@ func TestUpdateUser(t *testing.T) {
reqUrl := fmt.Sprintf("/api/v1/users/%s", adminUid)
UpdateUser(router)
r := PerformRequestWithBody(app, "PUT", reqUrl, "{foo:123}")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, http.StatusForbidden, r.Code)
})
}

View file

@ -40,8 +40,11 @@ func UploadUserFiles(router *gin.RouterGroup) {
return
}
uid := clean.UID(c.Param("uid"))
// Users may only upload their own files.
if s.User().UserUID != clean.UID(c.Param("uid")) {
if s.User().UserUID != uid {
event.AuditErr([]string{ClientIP(c), "session %s", "upload files", "user uid does not match"}, s.RefID)
AbortForbidden(c)
return
}

View file

@ -61,7 +61,7 @@ func passwdAction(ctx *cli.Context) error {
return fmt.Errorf("user %s has been deleted", clean.LogQuote(id))
}
log.Infof("please enter a new password for %s (minimum %d characters)\n", clean.Log(m.Name()), entity.PasswordLength)
log.Infof("please enter a new password for %s (minimum %d characters)\n", clean.Log(m.Username()), entity.PasswordLength)
newPassword := getPassword("New Password: ")
@ -79,7 +79,7 @@ func passwdAction(ctx *cli.Context) error {
return err
}
log.Infof("changed password for %s\n", clean.Log(m.Name()))
log.Infof("changed password for %s\n", clean.Log(m.Username()))
return nil
}

View file

@ -10,9 +10,8 @@ import (
const (
UserNameUsage = "full `NAME` for display in the interface"
UserEmailUsage = "unique `EMAIL` address of the user"
UserPasswordUsage = "`PASSWORD` for authentication"
UserRoleUsage = "user account `ROLE`"
UserAttrUsage = "custom user account `ATTRIBUTES`"
UserPasswordUsage = "`PASSWORD` for local authentication"
UserRoleUsage = "user role `NAME` (leave blank for default)"
UserAdminUsage = "make user super admin with full access"
UserNoLoginUsage = "disable login on the web interface"
UserWebDAVUsage = "allow to sync files via WebDAV"
@ -53,10 +52,6 @@ var UserFlags = []cli.Flag{
Usage: UserRoleUsage,
Value: acl.RoleAdmin.String(),
},
cli.StringFlag{
Name: "attr, a",
Usage: UserAttrUsage,
},
cli.BoolFlag{
Name: "superadmin, s",
Usage: UserAdminUsage,

View file

@ -47,7 +47,7 @@ func usersAddAction(ctx *cli.Context) error {
return err
}
frm.UserName = clean.DN(res)
frm.UserName = clean.Username(res)
}
// Check if account exists but is deleted.

View file

@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/report"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -23,7 +24,7 @@ var UsersListCommand = cli.Command{
// usersListAction displays existing user accounts.
func usersListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"UID", "Name", "User", "Email", "Role", "Super Admin", "Web Login", "WebDAV", "Attributes", "Created At"}
cols := []string{"UID", "Username", "Role", "Auth Provider", "Super Admin", "Web Login", "WebDAV", "Created At"}
// Fetch users from database.
users := query.RegisteredUsers()
@ -36,14 +37,12 @@ func usersListAction(ctx *cli.Context) error {
for i, user := range users {
rows[i] = []string{
user.UID(),
user.FullName(),
user.Login(),
user.Email(),
user.Username(),
user.AclRole().String(),
authn.ProviderString(user.Provider()),
report.Bool(user.SuperAdmin, report.Yes, report.No),
report.Bool(user.CanLogIn(), report.Enabled, report.Disabled),
report.Bool(user.CanUseWebDAV(), report.Enabled, report.Disabled),
user.Attr(),
txt.TimeStamp(&user.CreatedAt),
}
}

View file

@ -257,8 +257,8 @@ func (m *Session) SetUser(u *User) *Session {
return m
}
// Login returns the login name.
func (m *Session) Login() string {
// Username returns the login name.
func (m *Session) Username() string {
return m.UserName
}

View file

@ -10,27 +10,31 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
)
// Auth checks if the credentials are valid and returns the user and authentication provider.
var Auth = func(f form.Login, m *Session, c *gin.Context) (user *User, provider string, err error) {
name := f.Name()
name := f.Username()
user = FindUserByName(name)
err = AuthPassword(user, f, m)
err = AuthLocal(user, f, m)
if err != nil {
return user, ProviderNone, err
return user, authn.ProviderNone, err
}
return user, ProviderPassword, err
// Update login timestamp.
user.UpdateLoginTime()
return user, authn.ProviderLocal, err
}
// AuthPassword checks if the username and password are valid and returns the user.
func AuthPassword(user *User, f form.Login, m *Session) (err error) {
name := f.Name()
// AuthLocal authenticates against the local user database with the specified username and password.
func AuthLocal(user *User, f form.Login, m *Session) (err error) {
name := f.Username()
// User found?
if user == nil {

View file

@ -19,7 +19,7 @@ func (m *Session) Report(skipEmpty bool) (rows [][]string, cols []string) {
rows = make([][]string, 0, len(values))
for k, v := range values {
s := fmt.Sprintf("%v", v)
s := fmt.Sprintf("%#v", v)
// Skip empty values?
if !skipEmpty || s != "" {

View file

@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
@ -41,22 +42,22 @@ 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(255);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,omitempty"`
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"`
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:'';" 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"`
UserRole string `gorm:"size:64;default:'';" json:"Role" yaml:"Role,omitempty"`
UserAttr string `gorm:"size:1024;" json:"Attr" yaml:"Attr,omitempty"`
SuperAdmin bool `json:"SuperAdmin" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin" yaml:"CanLogin,omitempty"`
LoginAt *time.Time `json:"LoginAt" yaml:"LoginAt,omitempty"`
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
WebDAV bool `gorm:"column:webdav;" json:"WebDAV,omitempty" yaml:"WebDAV,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"`
CanInvite bool `json:"CanInvite,omitempty" yaml:"CanInvite,omitempty"`
WebDAV bool `gorm:"column:webdav;" json:"WebDAV" yaml:"WebDAV,omitempty"`
BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath" yaml:"BasePath,omitempty"`
UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath" yaml:"UploadPath,omitempty"`
CanInvite bool `json:"CanInvite" 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:"-"`
@ -106,6 +107,8 @@ func FindUser(find User) *User {
stmt = stmt.Where("id = ?", find.ID)
} else if rnd.IsUID(find.UserUID, UserUID) {
stmt = stmt.Where("user_uid = ?", find.UserUID)
} else if find.UserName != "" && find.AuthProvider != "" {
stmt = stmt.Where("user_name = ? AND (auth_provider = ? OR auth_provider = '')", find.UserName, find.AuthProvider)
} else if find.UserName != "" {
stmt = stmt.Where("user_name = ?", find.UserName)
} else if find.UserEmail != "" {
@ -142,14 +145,25 @@ func FirstOrCreateUser(m *User) *User {
}
// FindUserByName returns the matching user or nil if it was not found.
func FindUserByName(name string) *User {
name = clean.DN(name)
func FindUserByName(userName string) *User {
userName = clean.Username(userName)
if name == "" {
if userName == "" {
return nil
}
return FindUser(User{UserName: name})
return FindUser(User{UserName: userName})
}
// FindLocalUser returns the matching local user or nil if it was not found.
func FindLocalUser(userName string) *User {
userName = clean.Username(userName)
if userName == "" {
return nil
}
return FindUser(User{UserName: userName, AuthProvider: authn.ProviderLocal})
}
// FindUserByUID returns the matching user or nil if it was not found.
@ -209,7 +223,7 @@ func (m *User) InitAccount(initName, initPasswd string) (updated bool) {
// Change username if needed.
if initName != "" && initName != m.UserName {
if err := m.UpdateName(initName); err != nil {
if err := m.UpdateUsername(initName); err != nil {
event.AuditErr([]string{"user %s", "failed to change username to %s", "%s"}, m.RefID, clean.Log(initName), err)
}
}
@ -333,6 +347,25 @@ func (m *User) Disabled() bool {
return m.Deleted() || m.Expired() && !m.SuperAdmin
}
// UpdateLoginTime updates the login timestamp and returns it if successful.
func (m *User) UpdateLoginTime() *time.Time {
if m == nil {
return nil
} else if m.Deleted() {
return nil
}
timeStamp := TimePointer()
if err := Db().Model(m).UpdateColumn("LoginAt", timeStamp).Error; err != nil {
return nil
}
m.LoginAt = timeStamp
return timeStamp
}
// CanLogIn checks if the user is allowed to log in and use the web UI.
func (m *User) CanLogIn() bool {
if m == nil {
@ -416,7 +449,7 @@ func (m *User) SetUploadPath(dir string) *User {
// String returns an identifier that can be used in logs.
func (m *User) String() string {
if n := m.Name(); n != "" {
if n := m.Username(); n != "" {
return clean.LogQuote(n)
} else if n = m.FullName(); n != "" {
return clean.LogQuote(n)
@ -425,13 +458,52 @@ func (m *User) String() string {
return clean.Log(m.UserUID)
}
// Name returns the user's login name for authentication.
func (m *User) Name() string {
// Provider returns the authentication provider name.
func (m *User) Provider() string {
if m.AuthProvider != "" {
return m.AuthProvider
} else if m.ID == Visitor.ID {
return authn.ProviderToken
} else if m.ID == 1 {
return authn.ProviderLocal
} else if m.UserName != "" && m.ID > 0 {
return authn.ProviderDefault
}
return authn.ProviderNone
}
// SetProvider set the authentication provider.
func (m *User) SetProvider(s string) *User {
if m == nil {
return nil
} else if m.ID <= 0 {
return m
} else if s == authn.ProviderString(authn.ProviderDefault) {
s = ""
}
m.AuthProvider = clean.TypeLower(s)
return m
}
// IsLocal checks if the user is authenticated locally.
func (m *User) IsLocal() bool {
if m.UserName == "" || m.ID <= 0 {
return false
}
return m.ID == 1 || m.AuthProvider == authn.ProviderDefault || m.AuthProvider == authn.ProviderLocal
}
// Username returns the user's login name as sanitized string.
func (m *User) Username() string {
return clean.Username(m.UserName)
}
// SetName sets the login username to the specified string.
func (m *User) SetName(login string) (err error) {
// SetUsername sets the login username to the specified string.
func (m *User) SetUsername(login string) (err error) {
if m.ID < 0 {
return fmt.Errorf("system users cannot be modified")
}
@ -458,9 +530,9 @@ func (m *User) SetName(login string) (err error) {
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 {
// UpdateUsername changes the login username and saves it to the database.
func (m *User) UpdateUsername(login string) (err error) {
if err = m.SetUsername(login); err != nil {
return err
}
@ -558,6 +630,15 @@ func (m *User) NotRegistered() bool {
return !m.IsRegistered()
}
// Equal returns true if the user specified matches.
func (m *User) Equal(u *User) bool {
if m == nil || u == nil {
return false
}
return m.UserUID == u.UserUID
}
// IsAdmin checks if the user is an admin with username.
func (m *User) IsAdmin() bool {
if m == nil {
@ -670,12 +751,12 @@ func (m *User) WrongPassword(s string) bool {
// 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() == "" {
if m.Username() == "" {
return errors.New("username must not be empty")
}
// Name too short?
if len(m.Name()) < UsernameLength {
if len(m.Username()) < UsernameLength {
return fmt.Errorf("username must have at least %d characters", UsernameLength)
}
@ -723,7 +804,8 @@ func (m *User) Validate() (err error) {
// SetFormValues sets the values specified in the form.
func (m *User) SetFormValues(frm form.User) *User {
m.UserName = frm.Name()
m.UserName = frm.Username()
m.SetProvider(frm.AuthProvider)
m.UserEmail = frm.Email()
m.DisplayName = frm.DisplayName
m.SuperAdmin = frm.SuperAdmin
@ -853,9 +935,15 @@ func (m *User) Form() (form.User, error) {
}
// SaveForm updates the entity using form data and stores it in the database.
func (m *User) SaveForm(f form.User) error {
func (m *User) SaveForm(f form.User, updateRights bool) error {
if m.UserName == "" || m.ID <= 0 {
return fmt.Errorf("system users cannot be updated")
return fmt.Errorf("system users cannot be modified")
} else if (m.ID == 1 || f.SuperAdmin) && acl.RoleAdmin.NotEqual(f.Role()) {
return fmt.Errorf("super admin must not have a non-admin role")
} else if f.BasePath != "" && clean.UserPath(f.BasePath) == "" {
return fmt.Errorf("invalid base folder")
} else if f.UploadPath != "" && clean.UserPath(f.UploadPath) == "" {
return fmt.Errorf("invalid upload folder")
}
// Ignore details if not set.
@ -870,7 +958,7 @@ func (m *User) SaveForm(f form.User) error {
// Sanitize display name.
if n := clean.Name(f.DisplayName); n != "" && n != m.DisplayName {
m.SetDisplayName(n)
m.SetDisplayName(n, SrcManual)
}
// Sanitize email address.
@ -880,20 +968,34 @@ func (m *User) SaveForm(f form.User) error {
m.VerifyToken = GenerateToken()
}
// Update user rights only if explicitly requested.
if updateRights {
m.UserRole = f.UserRole
m.UserAttr = f.UserAttr
m.SuperAdmin = f.SuperAdmin
m.CanLogin = f.CanLogin
m.WebDAV = f.WebDAV
m.SetProvider(f.AuthProvider)
m.SetBasePath(f.BasePath)
m.SetUploadPath(f.UploadPath)
}
return m.Save()
}
// SetDisplayName sets a new display name and, if possible, splits it into its components.
func (m *User) SetDisplayName(name string) *User {
func (m *User) SetDisplayName(name, src string) *User {
name = clean.Name(name)
d := m.Details()
if name == "" || SrcPriority[SrcAuto] < SrcPriority[d.NameSrc] {
if name == "" || SrcPriority[src] < SrcPriority[d.NameSrc] {
return m
}
m.DisplayName = name
d.NameSrc = src
// Try to parse name into components.
n := txt.ParseName(name)
@ -935,19 +1037,3 @@ func (m *User) SetAvatar(thumb, thumbSrc string) error {
return m.Updates(Values{"Thumb": m.Thumb, "ThumbSrc": m.ThumbSrc})
}
// Login returns the username.
func (m *User) Login() string {
return m.UserName
}
// Provider returns the authentication provider name.
func (m *User) Provider() string {
if m.AuthProvider != "" {
return m.AuthProvider
} else if m.UserName != "" && m.ID > 0 {
return "password"
}
return ""
}

View file

@ -32,7 +32,7 @@ func AddUser(frm form.User) error {
return err
}
log.Infof("successfully added user %s", clean.LogQuote(user.Name()))
log.Infof("successfully added user %s", clean.LogQuote(user.Username()))
return nil
})

View file

@ -3,6 +3,7 @@ package entity
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
)
// Role defaults.
@ -17,6 +18,7 @@ const (
var Admin = User{
ID: 1,
UserName: AdminUserName,
AuthProvider: authn.ProviderLocal,
UserRole: acl.RoleAdmin.String(),
DisplayName: AdminDisplayName,
SuperAdmin: true,
@ -33,6 +35,7 @@ var UnknownUser = User{
ID: -1,
UserUID: "u000000000000001",
UserName: "",
AuthProvider: authn.ProviderNone,
UserRole: acl.RoleUnknown.String(),
CanLogin: false,
WebDAV: false,
@ -48,6 +51,7 @@ var Visitor = User{
ID: -2,
UserUID: "u000000000000002",
UserName: "",
AuthProvider: authn.ProviderToken,
UserRole: acl.RoleVisitor.String(),
DisplayName: VisitorDisplayName,
CanLogin: false,

View file

@ -12,14 +12,14 @@ func TestUserMap_Get(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
r := UserFixtures.Get("alice")
assert.Equal(t, "alice", r.UserName)
assert.Equal(t, "alice", r.Name())
assert.Equal(t, "alice", r.Username())
assert.IsType(t, User{}, r)
})
t.Run("Invalid", func(t *testing.T) {
r := UserFixtures.Get("monstera")
assert.Equal(t, "", r.UserName)
assert.Equal(t, "", r.Name())
assert.Equal(t, "", r.Username())
assert.IsType(t, User{}, r)
})
}
@ -27,7 +27,7 @@ func TestUserMap_Get(t *testing.T) {
func TestUserMap_Pointer(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
r := UserFixtures.Pointer("alice")
assert.Equal(t, "alice", r.Name())
assert.Equal(t, "alice", r.Username())
assert.Equal(t, "alice", r.UserName)
assert.Equal(t, "alice@example.com", r.Email())
assert.Equal(t, "alice@example.com", r.UserEmail)

View file

@ -19,7 +19,7 @@ func (m *User) Report(skipEmpty bool) (rows [][]string, cols []string) {
rows = make([][]string, 0, len(values))
for k, v := range values {
s := fmt.Sprintf("%v", v)
s := fmt.Sprintf("%#v", v)
// Skip empty values?
if !skipEmpty || s != "" {

View file

@ -7,6 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/rnd"
)
@ -17,6 +18,89 @@ func TestNewUser(t *testing.T) {
assert.True(t, rnd.IsUID(m.UserUID, UserUID))
}
func TestFindLocalUser(t *testing.T) {
t.Run("Admin", func(t *testing.T) {
m := FindLocalUser("admin")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, 1, m.ID)
assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "admin", m.UserName)
assert.Equal(t, "admin", m.Username())
m.UserName = "Admin "
assert.Equal(t, "admin", m.Username())
assert.Equal(t, "Admin ", m.UserName)
assert.Equal(t, "Admin", m.DisplayName)
assert.Equal(t, acl.RoleAdmin, m.AclRole())
assert.Equal(t, "", m.Attr())
assert.False(t, m.IsVisitor())
assert.True(t, m.SuperAdmin)
assert.True(t, m.CanLogin)
assert.True(t, m.CanInvite)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Alice", func(t *testing.T) {
m := FindLocalUser("alice")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, 5, m.ID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
assert.Equal(t, "alice", m.UserName)
assert.Equal(t, "Alice", m.DisplayName)
assert.Equal(t, "alice@example.com", m.UserEmail)
assert.True(t, m.SuperAdmin)
assert.Equal(t, acl.RoleAdmin, m.AclRole())
assert.NotEqual(t, acl.RoleVisitor, m.AclRole())
assert.False(t, m.IsVisitor())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Bob", func(t *testing.T) {
m := FindLocalUser("bob")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, 7, m.ID)
assert.Equal(t, "uqxc08w3d0ej2283", m.UserUID)
assert.Equal(t, "bob", m.UserName)
assert.Equal(t, "Robert Rich", m.DisplayName)
assert.Equal(t, "bob@example.com", m.UserEmail)
assert.False(t, m.SuperAdmin)
assert.False(t, m.IsVisitor())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Unknown", func(t *testing.T) {
m := FindLocalUser("")
if m != nil {
t.Fatal("result should be nil")
}
})
t.Run("NotFound", func(t *testing.T) {
m := FindLocalUser("xxx")
if m != nil {
t.Fatal("result should be nil")
}
})
}
func TestFindUserByName(t *testing.T) {
t.Run("Admin", func(t *testing.T) {
m := FindUserByName("admin")
@ -28,9 +112,9 @@ func TestFindUserByName(t *testing.T) {
assert.Equal(t, 1, m.ID)
assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "admin", m.UserName)
assert.Equal(t, "admin", m.Name())
assert.Equal(t, "admin", m.Username())
m.UserName = "Admin "
assert.Equal(t, "admin", m.Name())
assert.Equal(t, "admin", m.Username())
assert.Equal(t, "Admin ", m.UserName)
assert.Equal(t, "Admin", m.DisplayName)
assert.Equal(t, acl.RoleAdmin, m.AclRole())
@ -114,10 +198,10 @@ func TestUser_Create(t *testing.T) {
t.Fatal(err)
}
assert.Equal(t, "example", m.Name())
assert.Equal(t, "example", m.Username())
assert.Equal(t, "example", m.UserName)
if err := m.UpdateName("example-editor"); err == nil {
if err := m.UpdateUsername("example-editor"); err == nil {
t.Fatal("error expected")
}
})
@ -136,14 +220,14 @@ func TestUser_SetName(t *testing.T) {
t.Fatal("result should not be nil")
}
assert.Equal(t, "admin", m.Name())
assert.Equal(t, "admin", m.Username())
assert.Equal(t, "admin", m.UserName)
if err := m.SetName("photoprism"); err != nil {
if err := m.SetUsername("photoprism"); err != nil {
t.Fatal(err)
}
assert.Equal(t, "photoprism", m.Name())
assert.Equal(t, "photoprism", m.Username())
assert.Equal(t, "photoprism", m.UserName)
})
}
@ -328,7 +412,7 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, 5, m.ID)
assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID)
assert.Equal(t, "alice", m.Name())
assert.Equal(t, "alice", m.Username())
assert.Equal(t, "Alice", m.DisplayName)
assert.Equal(t, "alice@example.com", m.UserEmail)
assert.True(t, m.SuperAdmin)
@ -697,7 +781,20 @@ func TestUser_Disabled(t *testing.T) {
assert.True(t, UserFixtures.Pointer("deleted").Disabled())
}
func TestUser_CanUseAPI(t *testing.T) {
func TestUser_UpdateLoginTime(t *testing.T) {
alice := UserFixtures.Get("alice")
time1 := alice.LoginAt
assert.Nil(t, time1)
alice.UpdateLoginTime()
time2 := alice.LoginAt
assert.NotNil(t, time2)
alice.UpdateLoginTime()
time3 := alice.LoginAt
assert.NotNil(t, time3)
assert.True(t, time3.After(*time2) || time3.Equal(*time2))
}
func TestUser_CanLogIn(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").CanLogIn())
assert.False(t, UserFixtures.Pointer("deleted").CanLogIn())
}
@ -752,7 +849,7 @@ func TestUser_SaveForm(t *testing.T) {
frm, err := UnknownUser.Form()
assert.NoError(t, err)
err = UnknownUser.SaveForm(frm)
err = UnknownUser.SaveForm(frm, false)
assert.Error(t, err)
})
t.Run("Admin", func(t *testing.T) {
@ -770,7 +867,32 @@ func TestUser_SaveForm(t *testing.T) {
frm.UserEmail = "admin@example.com"
frm.UserDetails.UserLocation = "GoLand"
err = Admin.SaveForm(frm)
err = Admin.SaveForm(frm, false)
assert.NoError(t, err)
assert.Equal(t, "admin@example.com", Admin.UserEmail)
assert.Equal(t, "GoLand", Admin.Details().UserLocation)
m = FindUserByUID(Admin.UserUID)
assert.Equal(t, "admin@example.com", m.UserEmail)
assert.Equal(t, "GoLand", m.Details().UserLocation)
})
t.Run("UpdateRights", func(t *testing.T) {
m := FindUser(Admin)
if m == nil {
t.Fatal("result should not be nil")
}
frm, err := m.Form()
if err != nil {
t.Fatal(err)
}
frm.UserEmail = "admin@example.com"
frm.UserDetails.UserLocation = "GoLand"
err = Admin.SaveForm(frm, true)
assert.NoError(t, err)
assert.Equal(t, "admin@example.com", Admin.UserEmail)
@ -785,7 +907,7 @@ func TestUser_SaveForm(t *testing.T) {
func TestUser_SetDisplayName(t *testing.T) {
t.Run("BillGates", func(t *testing.T) {
user := NewUser()
user.SetDisplayName("Sir William Henry Gates III")
user.SetDisplayName("Sir William Henry Gates III", SrcAuto)
d := user.Details()
assert.Equal(t, "Sir", d.NameTitle)
assert.Equal(t, "William", d.GivenName)
@ -815,27 +937,27 @@ func TestUser_SetAvatar(t *testing.T) {
})
}
func TestUser_Login(t *testing.T) {
func TestUser_Username(t *testing.T) {
t.Run("Visitor", func(t *testing.T) {
assert.Equal(t, "", Visitor.Login())
assert.Equal(t, "", Visitor.Username())
})
t.Run("UnknownUser", func(t *testing.T) {
assert.Equal(t, "", UnknownUser.Login())
assert.Equal(t, "", UnknownUser.Username())
})
t.Run("Admin", func(t *testing.T) {
assert.Equal(t, "admin", Admin.Login())
assert.Equal(t, "admin", Admin.Username())
})
}
func TestUser_Provider(t *testing.T) {
t.Run("Visitor", func(t *testing.T) {
assert.Equal(t, "", Visitor.Provider())
assert.Equal(t, authn.ProviderToken, Visitor.Provider())
})
t.Run("UnknownUser", func(t *testing.T) {
assert.Equal(t, "", UnknownUser.Provider())
assert.Equal(t, authn.ProviderNone, UnknownUser.Provider())
})
t.Run("Admin", func(t *testing.T) {
assert.Equal(t, "password", Admin.Provider())
assert.Equal(t, authn.ProviderLocal, Admin.Provider())
})
}
@ -918,7 +1040,7 @@ func TestUser_Handle(t *testing.T) {
CanInvite: false,
}
assert.Equal(t, "mr-happy@cat.com", u.Login())
assert.Equal(t, "mr-happy@cat.com", u.Username())
assert.Equal(t, "mr-happy", u.Handle())
u.UserName = "mr.happy@cat.com"
@ -959,7 +1081,7 @@ func TestUser_FullName(t *testing.T) {
assert.Equal(t, "Foo", u.FullName())
u.SetDisplayName("Jane Doe")
u.SetDisplayName("Jane Doe", SrcManual)
assert.Equal(t, "Jane Doe", u.FullName())
})

View file

@ -50,9 +50,3 @@ const (
IsStackable int8 = 0
IsUnstacked int8 = -1
)
// Authentication providers.
const (
ProviderNone = ""
ProviderPassword = "password"
)

View file

@ -11,6 +11,7 @@ const (
SrcEstimate = "estimate" // Prio 2
SrcName = "name" // Prio 4
SrcYaml = "yaml" // Prio 8
SrcLDAP = "ldap" // Prio 8
SrcLocation = classify.SrcLocation // Prio 8
SrcMarker = "marker" // Prio 8
SrcImage = classify.SrcImage // Prio 8
@ -37,6 +38,7 @@ var SrcPriority = Priorities{
SrcEstimate: 2,
SrcName: 4,
SrcYaml: 8,
SrcLDAP: 8,
SrcLocation: 8,
SrcMarker: 8,
SrcImage: 8,

View file

@ -41,7 +41,7 @@ type SearchPhotos struct {
Archived bool `form:"archived" notes:"Finds archived pictures"`
Public bool `form:"public" notes:"Excludes private pictures"`
Private bool `form:"private" notes:"Finds private pictures"`
Favorite bool `form:"favorite" notes:"Finds pictures marked as favorite"`
Favorite bool `form:"favorite" notes:"Finds favorites only"`
Unsorted bool `form:"unsorted" notes:"Finds pictures not in an album"`
Lat float32 `form:"lat" notes:"Latitude (GPS Position)"`
Lng float32 `form:"lng" notes:"Longitude (GPS Position)"`

View file

@ -0,0 +1,28 @@
package form
// SearchUsers represents a user search form.
type SearchUsers struct {
User string `form:"user"`
Query string `form:"q"`
Name string `form:"name"`
Email string `form:"email"`
Count int `form:"count" binding:"required" serialize:"-"`
Offset int `form:"offset" serialize:"-"`
Order string `form:"order" serialize:"-"`
}
func (f *SearchUsers) GetQuery() string {
return f.Query
}
func (f *SearchUsers) SetQuery(q string) {
f.Query = q
}
func (f *SearchUsers) ParseQueryString() error {
return ParseQueryString(f)
}
func NewSearchUsers(query string) SearchUsers {
return SearchUsers{Query: query}
}

View file

@ -9,6 +9,7 @@ import (
// User represents a user account form.
type User struct {
UserName string `json:"Name,omitempty" yaml:"Name,omitempty"`
AuthProvider string `json:"Provider,omitempty" yaml:"Provider,omitempty"`
UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
@ -26,6 +27,7 @@ type User struct {
func NewUserFromCli(ctx *cli.Context) User {
return User{
UserName: clean.Username(ctx.Args().First()),
AuthProvider: clean.TypeLower(ctx.String("provider")),
UserEmail: clean.Email(ctx.String("email")),
DisplayName: clean.Name(ctx.String("name")),
UserRole: clean.Role(ctx.String("role")),
@ -39,11 +41,16 @@ func NewUserFromCli(ctx *cli.Context) User {
}
}
// Name returns the sanitized username in lowercase.
func (f *User) Name() string {
// Username returns the sanitized username in lowercase.
func (f *User) Username() string {
return clean.Username(f.UserName)
}
// Provider returns the sanitized auth provider name.
func (f *User) Provider() string {
return clean.TypeLower(f.AuthProvider)
}
// Email returns the sanitized email in lowercase.
func (f *User) Email() string {
return clean.Email(f.UserEmail)

View file

@ -12,9 +12,9 @@ type Login struct {
AuthToken string `json:"token,omitempty"`
}
// Name returns the sanitized username in lowercase.
func (f Login) Name() string {
return clean.DN(f.UserName)
// Username returns the sanitized username in lowercase.
func (f Login) Username() string {
return clean.Username(f.UserName)
}
// Email returns the sanitized email in lowercase.
@ -22,9 +22,9 @@ func (f Login) Email() string {
return clean.Email(f.UserEmail)
}
// HasName checks if a username is set.
func (f Login) HasName() bool {
if l := len(f.Name()); l == 0 || l > 255 {
// HasUsername checks if a username is set.
func (f Login) HasUsername() bool {
if l := len(f.Username()); l == 0 || l > 255 {
return false
}
return true
@ -42,5 +42,5 @@ func (f Login) HasToken() bool {
// HasCredentials checks if all credentials is set.
func (f Login) HasCredentials() bool {
return f.HasName() && f.HasPassword()
return f.HasUsername() && f.HasPassword()
}

View file

@ -20,11 +20,11 @@ func TestLogin_HasToken(t *testing.T) {
func TestLogin_HasName(t *testing.T) {
t.Run("false", func(t *testing.T) {
form := &Login{UserEmail: "test@test.com", Password: "passwd", AuthToken: ""}
assert.Equal(t, false, form.HasName())
assert.Equal(t, false, form.HasUsername())
})
t.Run("true", func(t *testing.T) {
form := &Login{UserEmail: "test@test.com", UserName: "John", Password: "passwd", AuthToken: "123"}
assert.Equal(t, true, form.HasName())
assert.Equal(t, true, form.HasUsername())
})
}

View file

@ -14,18 +14,18 @@ func TestUser(t *testing.T) {
assert.Equal(t, "passwd", form.Password)
}
func TestUser_Name(t *testing.T) {
func TestUser_Username(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
form := &User{UserName: "", UserEmail: "test@test.com", Password: "passwd"}
assert.Equal(t, "", form.Name())
assert.Equal(t, "", form.Username())
})
t.Run("Valid", func(t *testing.T) {
form := &User{UserName: "foobar", UserEmail: "test@test.com", Password: "passwd"}
assert.Equal(t, "foobar", form.Name())
assert.Equal(t, "foobar", form.Username())
})
t.Run("Invalid", func(t *testing.T) {
form := &User{UserName: " Foo Bar4w45 !", UserEmail: "test@test.com", Password: "passwd"}
assert.Equal(t, "foobar4w45", form.Name())
assert.Equal(t, "foo bar4w45 !", form.Username())
})
}

View file

@ -11,7 +11,7 @@ func TestRegisteredUsers(t *testing.T) {
users := RegisteredUsers()
for _, user := range users {
t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.Name(), user.DisplayName)
t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.Username(), user.DisplayName)
assert.NotEmpty(t, user.UserUID)
}

49
internal/search/users.go Normal file
View file

@ -0,0 +1,49 @@
package search
import (
"strings"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
// Users finds registered users.
func Users(f form.SearchUsers) (result entity.Users, err error) {
result = entity.Users{}
stmt := Db()
search := strings.TrimSpace(f.Query)
sortOrder := f.Order
limit := f.Count
offset := f.Offset
if search == "all" {
// Don't filter.
} else if id := txt.Int(search); id != 0 {
stmt = stmt.Where("id = ?", id)
} else if rnd.IsUID(search, entity.UserUID) {
stmt = stmt.Where("user_uid = ?", search)
} else if search != "" {
stmt = stmt.Where("user_name LIKE ? OR user_email LIKE ? OR display_name LIKE ?", search+"%", search+"%", search+"%")
} else {
stmt = stmt.Where("id > 0")
}
if sortOrder == "" {
sortOrder = "user_name, id"
}
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

@ -7,6 +7,8 @@ import (
"github.com/photoprism/photoprism/internal/config"
)
var APIv1 *gin.RouterGroup
// registerRoutes configures the available web server routes.
func registerRoutes(router *gin.Engine, conf *config.Config) {
// Enables automatic redirection if the current route cannot be matched but a
@ -26,142 +28,139 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
registerSharingRoutes(router, conf)
// JSON-REST API Version 1
v1 := router.Group(conf.BaseUri(config.ApiUri))
{
// Authentication.
api.CreateSession(v1)
api.GetSession(v1)
api.DeleteSession(v1)
api.CreateSession(APIv1)
api.GetSession(APIv1)
api.DeleteSession(APIv1)
// Global Config.
api.GetConfigOptions(v1)
api.SaveConfigOptions(v1)
api.GetConfigOptions(APIv1)
api.SaveConfigOptions(APIv1)
// Custom Settings.
api.GetClientConfig(v1)
api.GetSettings(v1)
api.SaveSettings(v1)
api.GetClientConfig(APIv1)
api.GetSettings(APIv1)
api.SaveSettings(APIv1)
// Profile and Uploads.
api.UploadUserFiles(v1)
api.ProcessUserUpload(v1)
api.UploadUserAvatar(v1)
api.UpdateUserPassword(v1)
api.UpdateUser(v1)
api.UploadUserFiles(APIv1)
api.ProcessUserUpload(APIv1)
api.UploadUserAvatar(APIv1)
api.UpdateUserPassword(APIv1)
api.UpdateUser(APIv1)
// Service Accounts.
api.SearchServices(v1)
api.GetService(v1)
api.GetServiceFolders(v1)
api.UploadToService(v1)
api.AddService(v1)
api.DeleteService(v1)
api.UpdateService(v1)
api.SearchServices(APIv1)
api.GetService(APIv1)
api.GetServiceFolders(APIv1)
api.UploadToService(APIv1)
api.AddService(APIv1)
api.DeleteService(APIv1)
api.UpdateService(APIv1)
// Thumbnail Images.
api.GetThumb(v1)
api.GetThumb(APIv1)
// Video Streaming.
api.GetVideo(v1)
api.GetVideo(APIv1)
// Downloads.
api.GetDownload(v1)
api.ZipCreate(v1)
api.ZipDownload(v1)
api.GetDownload(APIv1)
api.ZipCreate(APIv1)
api.ZipDownload(APIv1)
// Index and Import.
api.StartImport(v1)
api.CancelImport(v1)
api.StartIndexing(v1)
api.CancelIndexing(v1)
api.StartImport(APIv1)
api.CancelImport(APIv1)
api.StartIndexing(APIv1)
api.CancelIndexing(APIv1)
// Photo Search and Organization.
api.SearchPhotos(v1)
api.SearchGeo(v1)
api.GetPhoto(v1)
api.GetPhotoYaml(v1)
api.UpdatePhoto(v1)
api.GetPhotoDownload(v1)
// api.GetPhotoLinks(v1)
// api.CreatePhotoLink(v1)
// api.UpdatePhotoLink(v1)
// api.DeletePhotoLink(v1)
api.ApprovePhoto(v1)
api.LikePhoto(v1)
api.DislikePhoto(v1)
api.AddPhotoLabel(v1)
api.RemovePhotoLabel(v1)
api.UpdatePhotoLabel(v1)
api.GetMomentsTime(v1)
api.GetFile(v1)
api.DeleteFile(v1)
api.UpdateMarker(v1)
api.ClearMarkerSubject(v1)
api.PhotoPrimary(v1)
api.PhotoUnstack(v1)
api.SearchPhotos(APIv1)
api.SearchGeo(APIv1)
api.GetPhoto(APIv1)
api.GetPhotoYaml(APIv1)
api.UpdatePhoto(APIv1)
api.GetPhotoDownload(APIv1)
// api.GetPhotoLinks(APIv1)
// api.CreatePhotoLink(APIv1)
// api.UpdatePhotoLink(APIv1)
// api.DeletePhotoLink(APIv1)
api.ApprovePhoto(APIv1)
api.LikePhoto(APIv1)
api.DislikePhoto(APIv1)
api.AddPhotoLabel(APIv1)
api.RemovePhotoLabel(APIv1)
api.UpdatePhotoLabel(APIv1)
api.GetMomentsTime(APIv1)
api.GetFile(APIv1)
api.DeleteFile(APIv1)
api.UpdateMarker(APIv1)
api.ClearMarkerSubject(APIv1)
api.PhotoPrimary(APIv1)
api.PhotoUnstack(APIv1)
// Photo Albums.
api.SearchAlbums(v1)
api.GetAlbum(v1)
api.AlbumCover(v1)
api.CreateAlbum(v1)
api.UpdateAlbum(v1)
api.DeleteAlbum(v1)
api.DownloadAlbum(v1)
api.GetAlbumLinks(v1)
api.CreateAlbumLink(v1)
api.UpdateAlbumLink(v1)
api.DeleteAlbumLink(v1)
api.LikeAlbum(v1)
api.DislikeAlbum(v1)
api.CloneAlbums(v1)
api.AddPhotosToAlbum(v1)
api.RemovePhotosFromAlbum(v1)
api.SearchAlbums(APIv1)
api.GetAlbum(APIv1)
api.AlbumCover(APIv1)
api.CreateAlbum(APIv1)
api.UpdateAlbum(APIv1)
api.DeleteAlbum(APIv1)
api.DownloadAlbum(APIv1)
api.GetAlbumLinks(APIv1)
api.CreateAlbumLink(APIv1)
api.UpdateAlbumLink(APIv1)
api.DeleteAlbumLink(APIv1)
api.LikeAlbum(APIv1)
api.DislikeAlbum(APIv1)
api.CloneAlbums(APIv1)
api.AddPhotosToAlbum(APIv1)
api.RemovePhotosFromAlbum(APIv1)
// Photo Labels.
api.SearchLabels(v1)
api.LabelCover(v1)
api.UpdateLabel(v1)
// api.GetLabelLinks(v1)
// api.CreateLabelLink(v1)
// api.UpdateLabelLink(v1)
// api.DeleteLabelLink(v1)
api.LikeLabel(v1)
api.DislikeLabel(v1)
api.SearchLabels(APIv1)
api.LabelCover(APIv1)
api.UpdateLabel(APIv1)
// api.GetLabelLinks(APIv1)
// api.CreateLabelLink(APIv1)
// api.UpdateLabelLink(APIv1)
// api.DeleteLabelLink(APIv1)
api.LikeLabel(APIv1)
api.DislikeLabel(APIv1)
// Files and Folders.
api.SearchFoldersOriginals(v1)
api.SearchFoldersImport(v1)
api.FolderCover(v1)
api.SearchFoldersOriginals(APIv1)
api.SearchFoldersImport(APIv1)
api.FolderCover(APIv1)
// People.
api.SearchSubjects(v1)
api.GetSubject(v1)
api.UpdateSubject(v1)
api.LikeSubject(v1)
api.DislikeSubject(v1)
api.SearchSubjects(APIv1)
api.GetSubject(APIv1)
api.UpdateSubject(APIv1)
api.LikeSubject(APIv1)
api.DislikeSubject(APIv1)
// Faces.
api.SearchFaces(v1)
api.GetFace(v1)
api.UpdateFace(v1)
api.SearchFaces(APIv1)
api.GetFace(APIv1)
api.UpdateFace(APIv1)
// Batch Operations.
api.BatchPhotosApprove(v1)
api.BatchPhotosArchive(v1)
api.BatchPhotosRestore(v1)
api.BatchPhotosPrivate(v1)
api.BatchPhotosDelete(v1)
api.BatchAlbumsDelete(v1)
api.BatchLabelsDelete(v1)
api.BatchPhotosApprove(APIv1)
api.BatchPhotosArchive(APIv1)
api.BatchPhotosRestore(APIv1)
api.BatchPhotosPrivate(APIv1)
api.BatchPhotosDelete(APIv1)
api.BatchAlbumsDelete(APIv1)
api.BatchLabelsDelete(APIv1)
// Technical Endpoints.
api.GetSvg(v1)
api.GetStatus(v1)
api.GetErrors(v1)
api.DeleteErrors(v1)
api.SendFeedback(v1)
api.Connect(v1)
api.WebSocket(v1)
}
api.GetSvg(APIv1)
api.GetStatus(APIv1)
api.GetErrors(APIv1)
api.DeleteErrors(APIv1)
api.SendFeedback(APIv1)
api.Connect(APIv1)
api.WebSocket(APIv1)
}

View file

@ -167,5 +167,5 @@ func MarkUploadAsFavorite(fileName string) {
}
// Log success.
log.Infof("webdav: marked %s as favorite", clean.Log(filepath.Base(fileName)))
log.Infof("webdav: flagged %s as favorite", clean.Log(filepath.Base(fileName)))
}

View file

@ -43,6 +43,9 @@ func Start(ctx context.Context, conf *config.Config) {
// Register common middleware.
router.Use(Recovery(), Security(conf), Logger())
// Create REST API router group.
APIv1 = router.Group(conf.BaseUri(config.ApiUri))
// Initialize package extensions.
Ext().Init(router, conf)

25
pkg/authn/authn.go Normal file
View file

@ -0,0 +1,25 @@
/*
Package authn helps integrate and abstract authentication providers.
Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package authn

19
pkg/authn/providers.go Normal file
View file

@ -0,0 +1,19 @@
package authn
// Authentication providers.
const (
ProviderDefault = ""
ProviderNone = "none"
ProviderToken = "token"
ProviderLocal = "local"
ProviderLDAP = "ldap"
)
// ProviderString returns the provider name as a string for use in logs and reports.
func ProviderString(s string) string {
if s == ProviderDefault {
return "default"
}
return s
}

View file

@ -0,0 +1,15 @@
package authn
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestProviderString(t *testing.T) {
assert.Equal(t, "default", ProviderString(""))
assert.Equal(t, "default", ProviderString(ProviderDefault))
assert.Equal(t, "none", ProviderString(ProviderNone))
assert.Equal(t, "local", ProviderString(ProviderLocal))
assert.Equal(t, "ldap", ProviderString(ProviderLDAP))
}

View file

@ -10,8 +10,8 @@ import (
var EmailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
// Username returns the sanitized username with trimmed whitespace and in lowercase.
func Username(s string) string {
// Handle returns the sanitized username with trimmed whitespace and in lowercase.
func Handle(s string) string {
// Remove unwanted characters.
s = strings.Map(func(r rune) rune {
if r <= 42 || r == 127 {
@ -32,8 +32,8 @@ func Username(s string) string {
return strings.ToLower(s)
}
// DN returns the sanitized distinguished name (DN) with trimmed whitespace and in lowercase.
func DN(s string) string {
// Username returns the sanitized distinguished name (Username) with trimmed whitespace and in lowercase.
func Username(s string) string {
s = strings.TrimSpace(s)
// Remove unwanted characters.

View file

@ -6,12 +6,24 @@ import (
"github.com/stretchr/testify/assert"
)
func TestHandle(t *testing.T) {
t.Run("Admin ", func(t *testing.T) {
assert.Equal(t, "admin", Handle("Admin "))
})
t.Run(" Admin ", func(t *testing.T) {
assert.Equal(t, "adminfoo", Handle(" Admin@foo "))
})
t.Run(" admin ", func(t *testing.T) {
assert.Equal(t, "admin", Handle(" admin "))
})
}
func TestUsername(t *testing.T) {
t.Run("Admin ", func(t *testing.T) {
assert.Equal(t, "admin", Username("Admin "))
})
t.Run(" Admin ", func(t *testing.T) {
assert.Equal(t, "admin", Username(" Admin "))
assert.Equal(t, "admin@foo", Username(" Admin@foo "))
})
t.Run(" admin ", func(t *testing.T) {
assert.Equal(t, "admin", Username(" admin "))

View file

@ -10,7 +10,7 @@ func Empty(s string) bool {
return true
} else if s = strings.Trim(s, "%* "); s == "" || s == "0" || s == "-1" || DateTimeDefault(s) {
return true
} else if s = strings.ToLower(s); s == "nil" || s == "null" || s == "nan" {
} else if s = strings.ToLower(s); s == "nil" || s == "null" || s == "none" || s == "nan" {
return true
}