Auth: Refactor WebDAV login and increase maximum length of username

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-01-24 06:05:31 +01:00
parent 0643d54ffc
commit adc91fcf6e
11 changed files with 95 additions and 41 deletions

View file

@ -18,8 +18,11 @@ debug = true
baseDN = "dc=localssl,dc=dev"
[[users]]
name = "user"
givenname="John"
sn="Doe"
givenname = "John"
objectClass = "user"
displayName = "John Doe"
sn = "Doe"
userPrincipalName = "jdoe@example.com"
mail = "jdoe@example.com"
passsha256 = "4314c1fe282face45336b1422a3285c5ff31a39c8e24425615fa53a43b718493" # photoprism
[[users.customattributes]]
@ -29,9 +32,29 @@ debug = true
[[users.capabilities]]
action = "search"
object = "*"
[[users]]
name = "bob"
givenname = "Bob"
objectClass = "user"
displayName = "Robert Jones"
sn = "Jones"
userPrincipalName = "bob@example.com"
mail = "bob@example.com"
passsha256 = "4314c1fe282face45336b1422a3285c5ff31a39c8e24425615fa53a43b718493" # photoprism
[[users.customattributes]]
photoprismRoleUser = ["true"]
photoprismNoLogin = ["false"]
photoprismWebdav = ["true"]
photoprismUploadPath = ["bob"]
[[users.capabilities]]
action = "search"
object = "*"
[[users]]
name = "guest"
givenname="Guest"
objectClass = "user"
givenname = "Guest"
displayName = "Guest User"
userPrincipalName = "guest@example.com"
mail = "guest@example.com"
passsha256 = "4314c1fe282face45336b1422a3285c5ff31a39c8e24425615fa53a43b718493" # photoprism
[[users.customattributes]]

View file

@ -536,9 +536,7 @@
</v-list-tile-avatar>
<v-list-tile-content>
<v-list-tile-title>
{{ displayName }}
</v-list-tile-title>
<v-list-tile-title>{{ displayName }}</v-list-tile-title>
<v-list-tile-sub-title>{{ accountInfo }}</v-list-tile-sub-title>
</v-list-tile-content>

View file

@ -112,7 +112,7 @@ export default {
}
},
webdavUrl() {
let baseUrl = `${window.location.protocol}//${this.user.Name}@${window.location.host}/originals/`;
let baseUrl = `${window.location.protocol}//${encodeURIComponent(this.user.Name)}@${window.location.host}/originals/`;
if (this.user.BasePath) {
baseUrl = `${baseUrl}${this.user.BasePath}/`;

View file

@ -132,7 +132,7 @@ export class User extends RestModel {
getAccountInfo() {
if (this.Name) {
return `@${this.Name}`;
return this.Name;
} else if (this.Email) {
return this.Email;
} else if (this.Details && this.Details.JobTitle) {

2
go.mod
View file

@ -42,7 +42,7 @@ require (
github.com/tensorflow/tensorflow v1.15.2
github.com/tidwall/gjson v1.14.4
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
github.com/urfave/cli v1.22.11
github.com/urfave/cli v1.22.12
go4.org v0.0.0-20201209231011-d4a079459e60 // indirect
golang.org/x/crypto v0.5.0
golang.org/x/net v0.5.0

6
go.sum
View file

@ -207,6 +207,7 @@ github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzS
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
@ -282,7 +283,6 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpu/goacmedns v0.1.1/go.mod h1:MuaouqEhPAHxsbqjgnck5zeghuwBP1dLnPoobeGqugQ=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@ -963,8 +963,8 @@ github.com/ugorji/go/codec v1.2.8 h1:sgBJS6COt0b/P40VouWKdseidkDgHxYGm0SAglUHfP0
github.com/ugorji/go/codec v1.2.8/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 h1:TtyC78WMafNW8QFfv3TeP3yWNDG+uxNkk9vOrnDu6JA=
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6/go.mod h1:h8272+G2omSmi30fBXiZDMkmHuOgonplfKIKjQWzlfs=
github.com/urfave/cli v1.22.11 h1:3wLoofQeDAA/zDjLA4uvtzIv73+qdxJ3QkxfAqk4UVI=
github.com/urfave/cli v1.22.11/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli v1.22.12 h1:igJgVw1JdKH+trcLWLeLwZjU9fEfPesQ+9/e4MQ44S8=
github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8=
github.com/urfave/cli/v2 v2.11.2/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
github.com/urfave/cli/v2 v2.14.0/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI=
github.com/urfave/cli/v2 v2.19.2/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI=

View file

@ -35,31 +35,37 @@ func AuthPassword(user *User, f form.Login, m *Session) (err error) {
// User found?
if user == nil {
message := "account not found"
limiter.Login.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
if m != nil {
limiter.Login.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
}
return i18n.Error(i18n.ErrInvalidCredentials)
}
// Login allowed?
if !user.CanLogIn() {
message := "account disabled"
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
if m != nil {
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
}
return i18n.Error(i18n.ErrInvalidCredentials)
}
// Password valid?
if user.WrongPassword(f.Password) {
message := "incorrect password"
limiter.Login.Reserve(m.IP())
event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
if m != nil {
limiter.Login.Reserve(m.IP())
event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
m.Status = http.StatusUnauthorized
}
return i18n.Error(i18n.ErrInvalidCredentials)
} else {
} else if m != nil {
event.AuditInfo([]string{m.IP(), "session %s", "login as %s", "succeeded"}, m.RefID, clean.LogQuote(name))
event.LoginInfo(m.IP(), "api", name, m.UserAgent)
}

View file

@ -41,8 +41,8 @@ type User struct {
UUID string `gorm:"type:VARBINARY(64);column:user_uuid;index;" json:"UUID,omitempty" yaml:"UUID,omitempty"`
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;unique_index;" json:"UID" yaml:"UID"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,omitempty"`
UserName string `gorm:"size:64;index;" json:"Name" yaml:"Name,omitempty"`
AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID,omitempty" 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"`
@ -142,7 +142,7 @@ func FirstOrCreateUser(m *User) *User {
// FindUserByName returns the matching user or nil if it was not found.
func FindUserByName(name string) *User {
name = clean.Username(name)
name = clean.DN(name)
if name == "" {
return nil
@ -881,7 +881,7 @@ func (m *User) SetAvatar(thumb, thumbSrc string) error {
// Login returns the login name and provider.
func (m *User) Login() string {
if m.AuthProvider == "" {
if m.AuthProvider == "" || strings.ContainsRune(m.UserName, '@') {
return m.UserName
} else {
return fmt.Sprintf("%s@%s", m.UserName, m.AuthProvider)

View file

@ -14,7 +14,7 @@ type Login struct {
// Name returns the sanitized username in lowercase.
func (f Login) Name() string {
return clean.Username(f.UserName)
return clean.DN(f.UserName)
}
// Email returns the sanitized email in lowercase.

View file

@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/api"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/clean"
)
@ -84,13 +85,22 @@ func BasicAuth() gin.HandlerFunc {
basicAuthMutex.Lock()
defer basicAuthMutex.Unlock()
// Check authentication and authorization.
if user := entity.FindUserByName(name); user == nil {
// Username not found.
message := "account not found"
// User credentials.
f := form.Login{
UserName: name,
Password: password,
}
// Check credentials and authorization.
if user, _, err := entity.Auth(f, nil, c); err != nil {
message := err.Error()
limiter.Login.Reserve(clientIp)
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else if user == nil {
message := "account not found"
limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else if !user.CanUseWebDAV() {
// Sync disabled for this account.
@ -98,13 +108,6 @@ func BasicAuth() gin.HandlerFunc {
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else if ok = user.HasPassword(password); !ok {
// Wrong password.
message := "incorrect password"
limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else {
// Successfully authenticated.
event.AuditInfo([]string{clientIp, "webdav login as %s", "succeeded"}, clean.LogQuote(name))

View file

@ -32,6 +32,30 @@ 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 {
s = strings.TrimSpace(s)
// Remove unwanted characters.
s = strings.Map(func(r rune) rune {
if r <= 31 || r == 127 {
return -1
}
switch r {
case '"', ',', '+', '=', '`', '~', '?', '|', '*', '\\', ':', ';', '<', '>', '{', '}':
return -1
}
return r
}, s)
// Empty or too long?
if s == "" || reject(s, txt.ClipEmail) {
return ""
}
return strings.ToLower(s)
}
// Email returns the sanitized email with trimmed whitespace and in lowercase.
func Email(s string) string {
// Empty or too long?