Account: Add auth-related error messages to pkg/authn #808 #4114

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-03-29 12:16:26 +01:00
parent 9b4a162705
commit 37c3c9d624
25 changed files with 218 additions and 164 deletions

View File

@@ -6,6 +6,7 @@ import (
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header" "github.com/photoprism/photoprism/pkg/header"
) )
@@ -30,7 +31,7 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
// Find active session to perform authorization check or deny if no session was found. // Find active session to perform authorization check or deny if no session was found.
if s = Session(clientIp, authToken); s == nil { if s = Session(clientIp, authToken); s == nil {
event.AuditWarn([]string{clientIp, "%s %s without authentication", "denied"}, perms.String(), string(resource)) event.AuditWarn([]string{clientIp, "%s %s without authentication", authn.Denied}, perms.String(), string(resource))
return entity.SessionStatusUnauthorized() return entity.SessionStatusUnauthorized()
} }
@@ -42,32 +43,32 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
// on the allowed scope, the ACL, and the user account it belongs to (if any). // on the allowed scope, the ACL, and the user account it belongs to (if any).
if s.IsClient() { if s.IsClient() {
// Check the resource and required permissions against the session scope. // Check the resource and required permissions against the session scope.
if s.ScopeExcludes(resource, perms) { if s.InsufficientScope(resource, perms) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "access %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, string(resource)) event.AuditErr([]string{clientIp, "client %s", "session %s", "access %s", authn.ErrInsufficientScope.Error()}, clean.Log(s.ClientInfo()), s.RefID, string(resource))
return entity.SessionStatusForbidden() return entity.SessionStatusForbidden()
} }
// Check request authorization against client application ACL rules. // Check request authorization against client application ACL rules.
if acl.Rules.DenyAll(resource, s.ClientRole(), perms) { if acl.Rules.DenyAll(resource, s.ClientRole(), perms) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource)) event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", authn.Denied}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource))
return entity.SessionStatusForbidden() return entity.SessionStatusForbidden()
} }
// Also check the request authorization against the user's ACL rules? // Also check the request authorization against the user's ACL rules?
if s.NoUser() { if s.NoUser() {
// Allow access based on the ACL defaults for client applications. // Allow access based on the ACL defaults for client applications.
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource)) event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", authn.Granted}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource))
} else if u := s.User(); !u.IsDisabled() && !u.IsUnknown() && u.IsRegistered() { } else if u := s.User(); !u.IsDisabled() && !u.IsUnknown() && u.IsRegistered() {
if acl.Rules.DenyAll(resource, u.AclRole(), perms) { if acl.Rules.DenyAll(resource, u.AclRole(), perms) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource), u.String()) event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", authn.Denied}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource), u.String())
return entity.SessionStatusForbidden() return entity.SessionStatusForbidden()
} }
// Allow access based on the user role. // Allow access based on the user role.
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource), u.String()) event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", authn.Granted}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource), u.String())
} else { } else {
// Deny access if it is not a regular user account or the account has been disabled. // 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"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource)) event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as unauthorized user", authn.Denied}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource))
return entity.SessionStatusForbidden() return entity.SessionStatusForbidden()
} }
@@ -76,13 +77,13 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
// Otherwise, perform a regular ACL authorization check based on the user role. // Otherwise, perform a regular ACL authorization check based on the user role.
if u := s.User(); u.IsUnknown() || u.IsDisabled() { if u := s.User(); u.IsUnknown() || u.IsDisabled() {
event.AuditWarn([]string{clientIp, "session %s", "%s %s as unauthorized user", "denied"}, s.RefID, perms.String(), string(resource)) event.AuditWarn([]string{clientIp, "session %s", "%s %s as unauthorized user", authn.Denied}, s.RefID, perms.String(), string(resource))
return entity.SessionStatusUnauthorized() return entity.SessionStatusUnauthorized()
} else if acl.Rules.DenyAll(resource, u.AclRole(), perms) { } else if acl.Rules.DenyAll(resource, u.AclRole(), perms) {
event.AuditErr([]string{clientIp, "session %s", "%s %s as %s", "denied"}, s.RefID, perms.String(), string(resource), u.AclRole().String()) event.AuditErr([]string{clientIp, "session %s", "%s %s as %s", authn.Denied}, s.RefID, perms.String(), string(resource), u.AclRole().String())
return entity.SessionStatusForbidden() return entity.SessionStatusForbidden()
} else { } else {
event.AuditInfo([]string{clientIp, "session %s", "%s %s as %s", "granted"}, s.RefID, perms.String(), string(resource), u.AclRole().String()) event.AuditInfo([]string{clientIp, "session %s", "%s %s as %s", authn.Granted}, s.RefID, perms.String(), string(resource), u.AclRole().String())
return s return s
} }
} }

View File

@@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/i18n"
@@ -67,9 +68,9 @@ func StartImport(router *gin.RouterGroup) {
// To avoid conflicts, uploads are imported from "import_path/upload/session_ref/timestamp". // To avoid conflicts, uploads are imported from "import_path/upload/session_ref/timestamp".
if token := path.Base(srcFolder); token != "" && path.Dir(srcFolder) == UploadPath { if token := path.Base(srcFolder); token != "" && path.Dir(srcFolder) == UploadPath {
srcFolder = path.Join(UploadPath, s.RefID+token) 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.UserRole().String()) event.AuditInfo([]string{ClientIP(c), "session %s", "import uploads from %s as %s", authn.Granted}, s.RefID, clean.Log(srcFolder), s.UserRole().String())
} else if acl.Rules.Deny(acl.ResourceFiles, s.UserRole(), acl.ActionManage) { } else if acl.Rules.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()) event.AuditErr([]string{ClientIP(c), "session %s", "import files from %s as %s", authn.Denied}, s.RefID, clean.Log(srcFolder), s.UserRole().String())
AbortForbidden(c) AbortForbidden(c)
return return
} }

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get" "github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/session" "github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header" "github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/i18n"
@@ -50,12 +51,12 @@ func DeleteSession(router *gin.RouterGroup) {
// Only admins may delete other sessions by ref id. // Only admins may delete other sessions by ref id.
if rnd.IsRefID(id) { if rnd.IsRefID(id) {
if !acl.Rules.AllowAll(acl.ResourceSessions, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { if !acl.Rules.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()) event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", authn.Denied}, s.RefID, acl.ResourceSessions.String(), s.UserRole())
Abort(c, http.StatusForbidden, i18n.ErrForbidden) Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return return
} }
event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", "granted"}, s.RefID, acl.ResourceSessions.String(), s.UserRole()) event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", authn.Granted}, s.RefID, acl.ResourceSessions.String(), s.UserRole())
if s = entity.FindSessionByRefID(id); s == nil { if s = entity.FindSessionByRefID(id); s == nil {
Abort(c, http.StatusNotFound, i18n.ErrNotFound) Abort(c, http.StatusNotFound, i18n.ErrNotFound)

View File

@@ -144,7 +144,7 @@ func RevokeOAuthToken(router *gin.RouterGroup) {
// Abort if running in public mode. // Abort if running in public mode.
if get.Config().Public() { if get.Config().Public() {
event.AuditErr([]string{clientIp, "client", "delete session", "oauth2", "disabled in public mode"}) event.AuditErr([]string{clientIp, "client", "delete session", "oauth2", authn.ErrDisabledInPublicMode.Error()})
Abort(c, http.StatusForbidden, i18n.ErrForbidden) Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return return
} }
@@ -184,18 +184,18 @@ func RevokeOAuthToken(router *gin.RouterGroup) {
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized)) c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
return return
} else if sess == nil { } else if sess == nil {
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", "denied"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String()) event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", authn.Denied}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized)) c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
return return
} else if sess.Abort(c) { } else if sess.Abort(c) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", "denied"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String()) event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", authn.Denied}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
return return
} else if !sess.IsClient() { } else if !sess.IsClient() {
event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", "denied"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String()) event.AuditErr([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", authn.Denied}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
c.AbortWithStatusJSON(http.StatusForbidden, i18n.NewResponse(http.StatusForbidden, i18n.ErrForbidden)) c.AbortWithStatusJSON(http.StatusForbidden, i18n.NewResponse(http.StatusForbidden, i18n.ErrForbidden))
return return
} else { } else {
event.AuditInfo([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", "granted"}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String()) event.AuditInfo([]string{clientIp, "client %s", "session %s", "delete session as %s", "oauth2", authn.Granted}, clean.Log(sess.ClientInfo()), clean.Log(sess.RefID), sess.ClientRole().String())
} }
// Delete session cache and database record. // Delete session cache and database record.

View File

@@ -1,7 +1,6 @@
package commands package commands
import ( import (
"errors"
"fmt" "fmt"
"github.com/manifoldco/promptui" "github.com/manifoldco/promptui"
@@ -10,6 +9,7 @@ import (
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
@@ -53,10 +53,10 @@ func usersAddAction(ctx *cli.Context) error {
// Check if account exists but is deleted. // Check if account exists but is deleted.
if frm.UserName == "" { if frm.UserName == "" {
return fmt.Errorf("username is required") return authn.ErrUsernameRequired
} else if m := entity.FindUserByName(frm.UserName); m != nil { } else if m := entity.FindUserByName(frm.UserName); m != nil {
if !m.IsDeleted() { if !m.IsDeleted() {
return fmt.Errorf("user already exists") return authn.ErrAccountAlreadyExists
} }
prompt := promptui.Prompt{ prompt := promptui.Prompt{
@@ -65,7 +65,7 @@ func usersAddAction(ctx *cli.Context) error {
} }
if _, err := prompt.Run(); err != nil { if _, err := prompt.Run(); err != nil {
return fmt.Errorf("user already exists") return authn.ErrAccountAlreadyExists
} }
if err := m.RestoreFromCli(ctx, frm.Password); err != nil { if err := m.RestoreFromCli(ctx, frm.Password); err != nil {
@@ -96,7 +96,7 @@ func usersAddAction(ctx *cli.Context) error {
if len([]rune(input)) < entity.PasswordLength { if len([]rune(input)) < entity.PasswordLength {
return fmt.Errorf("password must have at least %d characters", entity.PasswordLength) return fmt.Errorf("password must have at least %d characters", entity.PasswordLength)
} else if len(input) > txt.ClipPassword { } else if len(input) > txt.ClipPassword {
return fmt.Errorf("password must have less than %d characters", txt.ClipPassword) return authn.ErrPasswordTooLong
} }
return nil return nil
} }
@@ -111,7 +111,7 @@ func usersAddAction(ctx *cli.Context) error {
} }
validateRetype := func(input string) error { validateRetype := func(input string) error {
if input != resPasswd { if input != resPasswd {
return errors.New("passwords do not match") return authn.ErrPasswordsDoNotMatch
} }
return nil return nil
} }
@@ -125,7 +125,7 @@ func usersAddAction(ctx *cli.Context) error {
return err return err
} }
if resConfirm != resPasswd { if resConfirm != resPasswd {
return errors.New("password is invalid, please try again") return authn.ErrInvalidPassword
} else { } else {
frm.Password = resPasswd frm.Password = resPasswd
} }

View File

@@ -220,7 +220,7 @@ func (c *Config) Propagate() {
// Set API preview and download default tokens. // Set API preview and download default tokens.
entity.PreviewToken.Set(c.PreviewToken(), entity.TokenConfig) entity.PreviewToken.Set(c.PreviewToken(), entity.TokenConfig)
entity.DownloadToken.Set(c.DownloadToken(), entity.TokenConfig) entity.DownloadToken.Set(c.DownloadToken(), entity.TokenConfig)
entity.CheckTokens = !c.Public() entity.ValidateTokens = !c.Public()
// Set face recognition parameters. // Set face recognition parameters.
face.ScoreThreshold = c.FaceScore() face.ScoreThreshold = c.FaceScore()

View File

@@ -34,11 +34,11 @@ func (c *Config) SetAuthMode(mode string) {
case AuthModePublic: case AuthModePublic:
c.options.AuthMode = AuthModePublic c.options.AuthMode = AuthModePublic
c.options.Public = true c.options.Public = true
entity.CheckTokens = false entity.ValidateTokens = false
default: default:
c.options.AuthMode = AuthModePasswd c.options.AuthMode = AuthModePasswd
c.options.Public = false c.options.Public = false
entity.CheckTokens = true entity.ValidateTokens = true
} }
} }

View File

@@ -376,7 +376,7 @@ func (m *Client) WrongSecret(s string) bool {
} }
// Invalid? // Invalid?
if pw.IsWrong(s) { if pw.Invalid(s) {
return true return true
} }

View File

@@ -518,8 +518,8 @@ func (m *Session) Scope() string {
return clean.Scope(m.AuthScope) return clean.Scope(m.AuthScope)
} }
// ScopeAllows checks if the scope does not exclude access to specified resource. // ValidateScope checks if the scope does not exclude access to specified resource.
func (m *Session) ScopeAllows(resource acl.Resource, perms acl.Permissions) bool { func (m *Session) ValidateScope(resource acl.Resource, perms acl.Permissions) bool {
// Get scope string. // Get scope string.
scope := m.Scope() scope := m.Scope()
@@ -556,9 +556,9 @@ func (m *Session) ScopeAllows(resource acl.Resource, perms acl.Permissions) bool
return true return true
} }
// ScopeExcludes checks if the scope does not include access to specified resource. // InsufficientScope checks if the scope does not include access to specified resource.
func (m *Session) ScopeExcludes(resource acl.Resource, perms acl.Permissions) bool { func (m *Session) InsufficientScope(resource acl.Resource, perms acl.Permissions) bool {
return !m.ScopeAllows(resource, perms) return !m.ValidateScope(resource, perms)
} }
// SetScope sets a custom authentication scope. // SetScope sets a custom authentication scope.

View File

@@ -83,7 +83,7 @@ func AuthLocal(user *User, f form.Login, m *Session, c *gin.Context) (provider a
// Check if user account exists. // Check if user account exists.
if user == nil { if user == nil {
message := "account not found" message := authn.ErrAccountNotFound.Error()
limiter.Login.Reserve(clientIp) limiter.Login.Reserve(clientIp)
if m != nil { if m != nil {
@@ -107,7 +107,7 @@ func AuthLocal(user *User, f form.Login, m *Session, c *gin.Context) (provider a
return provider, method, i18n.Error(i18n.ErrInvalidCredentials) return provider, method, i18n.Error(i18n.ErrInvalidCredentials)
} else if !user.CanLogIn() { } else if !user.CanLogIn() {
message := "account disabled" message := authn.ErrAccountDisabled.Error()
if m != nil { if m != nil {
event.AuditWarn([]string{clientIp, "session %s", "login as %s", message}, m.RefID, clean.LogQuote(userName)) event.AuditWarn([]string{clientIp, "session %s", "login as %s", message}, m.RefID, clean.LogQuote(userName))
@@ -121,14 +121,19 @@ func AuthLocal(user *User, f form.Login, m *Session, c *gin.Context) (provider a
// Authentication with personal access token if a valid secret has been provided as password. // Authentication with personal access token if a valid secret has been provided as password.
if authSess, authUser, authErr := AuthSession(f, c); authSess != nil && authUser != nil && authErr == nil { if authSess, authUser, authErr := AuthSession(f, c); authSess != nil && authUser != nil && authErr == nil {
if !authUser.IsRegistered() || authUser.UserUID != user.UserUID { if !authUser.IsRegistered() || authUser.UserUID != user.UserUID {
message := "incorrect user" message := authn.ErrInvalidUsername.Error()
limiter.Login.Reserve(clientIp) limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "session %s", "login as %s with app password", message}, m.RefID, clean.LogQuote(userName)) event.AuditErr([]string{clientIp, "session %s", "login as %s with app password", message}, m.RefID, clean.LogQuote(userName))
event.LoginError(clientIp, "api", userName, m.UserAgent, message) event.LoginError(clientIp, "api", userName, m.UserAgent, message)
m.Status = http.StatusUnauthorized m.Status = http.StatusUnauthorized
return provider, method, i18n.Error(i18n.ErrInvalidCredentials) return provider, method, i18n.Error(i18n.ErrInvalidCredentials)
} else if !authSess.IsClient() || authSess.ScopeExcludes(acl.ResourceSessions, acl.Permissions{acl.ActionCreate}) { } else if insufficientScope := authSess.InsufficientScope(acl.ResourceSessions, acl.Permissions{acl.ActionCreate}); insufficientScope || !authSess.IsClient() {
message := "unauthorized" var message string
if insufficientScope {
message = authn.ErrInsufficientScope.Error()
} else {
message = authn.ErrUnauthorized.Error()
}
limiter.Login.Reserve(clientIp) limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "session %s", "login as %s with app password", message}, m.RefID, clean.LogQuote(userName)) event.AuditErr([]string{clientIp, "session %s", "login as %s with app password", message}, m.RefID, clean.LogQuote(userName))
event.LoginError(clientIp, "api", userName, m.UserAgent, message) event.LoginError(clientIp, "api", userName, m.UserAgent, message)
@@ -149,7 +154,7 @@ func AuthLocal(user *User, f form.Login, m *Session, c *gin.Context) (provider a
// Otherwise, check account password. // Otherwise, check account password.
if user.WrongPassword(f.Password) { if user.WrongPassword(f.Password) {
message := "incorrect password" message := authn.ErrInvalidPassword.Error()
limiter.Login.Reserve(clientIp) limiter.Login.Reserve(clientIp)
if m != nil { if m != nil {
@@ -219,8 +224,9 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
// Redeem token. // Redeem token.
if user.IsRegistered() { if user.IsRegistered() {
if shares := user.RedeemToken(f.ShareToken); shares == 0 { if shares := user.RedeemToken(f.ShareToken); shares == 0 {
message := authn.ErrInvalidShareToken.Error()
limiter.Login.Reserve(m.IP()) limiter.Login.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "share token %s is invalid"}, m.RefID, clean.LogQuote(f.ShareToken)) event.AuditWarn([]string{m.IP(), "session %s", message}, m.RefID)
m.Status = http.StatusNotFound m.Status = http.StatusNotFound
return i18n.Error(i18n.ErrInvalidLink) return i18n.Error(i18n.ErrInvalidLink)
} else { } else {
@@ -230,9 +236,10 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
m.Status = http.StatusInternalServerError m.Status = http.StatusInternalServerError
return i18n.Error(i18n.ErrUnexpected) return i18n.Error(i18n.ErrUnexpected)
} else if shares := data.RedeemToken(f.ShareToken); shares == 0 { } else if shares := data.RedeemToken(f.ShareToken); shares == 0 {
message := authn.ErrInvalidShareToken.Error()
limiter.Login.Reserve(m.IP()) limiter.Login.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "share token %s is invalid"}, m.RefID, clean.LogQuote(f.ShareToken)) event.AuditWarn([]string{m.IP(), "session %s", message}, m.RefID)
event.LoginError(m.IP(), "api", "", m.UserAgent, "invalid share token") event.LoginError(m.IP(), "api", "", m.UserAgent, message)
m.Status = http.StatusNotFound m.Status = http.StatusNotFound
return i18n.Error(i18n.ErrInvalidLink) return i18n.Error(i18n.ErrInvalidLink)
} else { } else {

View File

@@ -110,8 +110,8 @@ func TestAuthSession(t *testing.T) {
assert.True(t, authSess.IsRegistered()) assert.True(t, authSess.IsRegistered())
assert.True(t, authSess.HasUser()) assert.True(t, authSess.HasUser())
assert.True(t, authSess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionCreate})) assert.True(t, authSess.ValidateScope(acl.ResourceWebDAV, acl.Permissions{acl.ActionCreate}))
assert.True(t, authSess.ScopeAllows(acl.ResourceSessions, acl.Permissions{acl.ActionCreate})) assert.True(t, authSess.ValidateScope(acl.ResourceSessions, acl.Permissions{acl.ActionCreate}))
}) })
t.Run("AliceTokenWebdav", func(t *testing.T) { t.Run("AliceTokenWebdav", func(t *testing.T) {
s := SessionFixtures.Get("alice_token_webdav") s := SessionFixtures.Get("alice_token_webdav")
@@ -148,8 +148,8 @@ func TestAuthSession(t *testing.T) {
assert.True(t, authSess.IsRegistered()) assert.True(t, authSess.IsRegistered())
assert.True(t, authSess.HasUser()) assert.True(t, authSess.HasUser())
assert.True(t, authSess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionCreate})) assert.True(t, authSess.ValidateScope(acl.ResourceWebDAV, acl.Permissions{acl.ActionCreate}))
assert.False(t, authSess.ScopeAllows(acl.ResourceSessions, acl.Permissions{acl.ActionCreate})) assert.False(t, authSess.ValidateScope(acl.ResourceSessions, acl.Permissions{acl.ActionCreate}))
}) })
t.Run("EmptyPassword", func(t *testing.T) { t.Run("EmptyPassword", func(t *testing.T) {
// Create test request form. // Create test request form.

View File

@@ -544,7 +544,7 @@ func TestSession_SetAuthID(t *testing.T) {
}) })
} }
func TestSession_ScopeAllows(t *testing.T) { func TestSession_ValidateScope(t *testing.T) {
t.Run("AnyScope", func(t *testing.T) { t.Run("AnyScope", func(t *testing.T) {
s := &Session{ s := &Session{
UserName: "test", UserName: "test",
@@ -552,7 +552,7 @@ func TestSession_ScopeAllows(t *testing.T) {
AuthScope: "*", AuthScope: "*",
} }
assert.True(t, s.ScopeAllows("", nil)) assert.True(t, s.ValidateScope("", nil))
}) })
t.Run("ReadScope", func(t *testing.T) { t.Run("ReadScope", func(t *testing.T) {
s := &Session{ s := &Session{
@@ -561,14 +561,14 @@ func TestSession_ScopeAllows(t *testing.T) {
AuthScope: "read", AuthScope: "read",
} }
assert.True(t, s.ScopeAllows("metrics", nil)) assert.True(t, s.ValidateScope("metrics", nil))
assert.True(t, s.ScopeAllows("sessions", nil)) assert.True(t, s.ValidateScope("sessions", nil))
assert.True(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) assert.True(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionView, acl.AccessAll}))
assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("settings", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionCreate})) assert.False(t, s.ValidateScope("settings", acl.Permissions{acl.ActionCreate}))
assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) assert.False(t, s.ValidateScope("sessions", acl.Permissions{acl.ActionDelete}))
}) })
t.Run("ReadAny", func(t *testing.T) { t.Run("ReadAny", func(t *testing.T) {
s := &Session{ s := &Session{
@@ -577,14 +577,14 @@ func TestSession_ScopeAllows(t *testing.T) {
AuthScope: "read *", AuthScope: "read *",
} }
assert.True(t, s.ScopeAllows("metrics", nil)) assert.True(t, s.ValidateScope("metrics", nil))
assert.True(t, s.ScopeAllows("sessions", nil)) assert.True(t, s.ValidateScope("sessions", nil))
assert.True(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) assert.True(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionView, acl.AccessAll}))
assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("settings", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionCreate})) assert.False(t, s.ValidateScope("settings", acl.Permissions{acl.ActionCreate}))
assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) assert.False(t, s.ValidateScope("sessions", acl.Permissions{acl.ActionDelete}))
}) })
t.Run("ReadSettings", func(t *testing.T) { t.Run("ReadSettings", func(t *testing.T) {
s := &Session{ s := &Session{
@@ -593,19 +593,19 @@ func TestSession_ScopeAllows(t *testing.T) {
AuthScope: "read settings", AuthScope: "read settings",
} }
assert.True(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionView})) assert.True(t, s.ValidateScope("settings", acl.Permissions{acl.ActionView}))
assert.False(t, s.ScopeAllows("metrics", nil)) assert.False(t, s.ValidateScope("metrics", nil))
assert.False(t, s.ScopeAllows("sessions", nil)) assert.False(t, s.ValidateScope("sessions", nil))
assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) assert.False(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionView, acl.AccessAll}))
assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionUpdate})) assert.False(t, s.ValidateScope("settings", acl.Permissions{acl.ActionUpdate}))
assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) assert.False(t, s.ValidateScope("sessions", acl.Permissions{acl.ActionDelete}))
assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) assert.False(t, s.ValidateScope("sessions", acl.Permissions{acl.ActionDelete}))
}) })
} }
func TestSession_ScopeExcludes(t *testing.T) { func TestSession_InsufficientScope(t *testing.T) {
t.Run("Empty", func(t *testing.T) { t.Run("Empty", func(t *testing.T) {
s := &Session{ s := &Session{
UserName: "test", UserName: "test",
@@ -613,7 +613,7 @@ func TestSession_ScopeExcludes(t *testing.T) {
AuthScope: "*", AuthScope: "*",
} }
assert.False(t, s.ScopeExcludes("", nil)) assert.False(t, s.InsufficientScope("", nil))
}) })
t.Run("ReadSettings", func(t *testing.T) { t.Run("ReadSettings", func(t *testing.T) {
s := &Session{ s := &Session{
@@ -622,15 +622,15 @@ func TestSession_ScopeExcludes(t *testing.T) {
AuthScope: "read settings", AuthScope: "read settings",
} }
assert.False(t, s.ScopeExcludes("settings", acl.Permissions{acl.ActionView})) assert.False(t, s.InsufficientScope("settings", acl.Permissions{acl.ActionView}))
assert.True(t, s.ScopeExcludes("metrics", nil)) assert.True(t, s.InsufficientScope("metrics", nil))
assert.True(t, s.ScopeExcludes("sessions", nil)) assert.True(t, s.InsufficientScope("sessions", nil))
assert.True(t, s.ScopeExcludes("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) assert.True(t, s.InsufficientScope("metrics", acl.Permissions{acl.ActionView, acl.AccessAll}))
assert.True(t, s.ScopeExcludes("metrics", acl.Permissions{acl.ActionUpdate})) assert.True(t, s.InsufficientScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.True(t, s.ScopeExcludes("metrics", acl.Permissions{acl.ActionUpdate})) assert.True(t, s.InsufficientScope("metrics", acl.Permissions{acl.ActionUpdate}))
assert.True(t, s.ScopeExcludes("settings", acl.Permissions{acl.ActionUpdate})) assert.True(t, s.InsufficientScope("settings", acl.Permissions{acl.ActionUpdate}))
assert.True(t, s.ScopeExcludes("sessions", acl.Permissions{acl.ActionDelete})) assert.True(t, s.InsufficientScope("sessions", acl.Permissions{acl.ActionDelete}))
assert.True(t, s.ScopeExcludes("sessions", acl.Permissions{acl.ActionDelete})) assert.True(t, s.InsufficientScope("sessions", acl.Permissions{acl.ActionDelete}))
}) })
} }

View File

@@ -9,7 +9,7 @@ const TokenPublic = "public"
var PreviewToken = NewStringMap(Strings{}) var PreviewToken = NewStringMap(Strings{})
var DownloadToken = NewStringMap(Strings{}) var DownloadToken = NewStringMap(Strings{})
var CheckTokens = true var ValidateTokens = true
// GenerateToken returns a random string token. // GenerateToken returns a random string token.
func GenerateToken() string { func GenerateToken() string {
@@ -18,10 +18,10 @@ func GenerateToken() string {
// InvalidDownloadToken checks if the token is unknown. // InvalidDownloadToken checks if the token is unknown.
func InvalidDownloadToken(t string) bool { func InvalidDownloadToken(t string) bool {
return CheckTokens && DownloadToken.Missing(t) return ValidateTokens && DownloadToken.Missing(t)
} }
// InvalidPreviewToken checks if the preview token is unknown. // InvalidPreviewToken checks if the preview token is unknown.
func InvalidPreviewToken(t string) bool { func InvalidPreviewToken(t string) bool {
return CheckTokens && PreviewToken.Missing(t) && DownloadToken.Missing(t) return ValidateTokens && PreviewToken.Missing(t) && DownloadToken.Missing(t)
} }

View File

@@ -889,7 +889,7 @@ func (m *User) WrongPassword(s string) bool {
} }
// Invalid? // Invalid?
if pw.IsWrong(s) { if pw.Invalid(s) {
return true return true
} }
@@ -924,7 +924,7 @@ func (m *User) VerifyPasscode(code string) (valid bool, passcode *Passcode, err
err = authn.ErrPasscodeRequired err = authn.ErrPasscodeRequired
} else if l := len(code); l < 1 || l > 255 { } else if l := len(code); l < 1 || l > 255 {
err = authn.ErrInvalidPasscode err = authn.ErrInvalidPasscode
} else if valid, recovery, err = passcode.Verify(code); recovery { } else if valid, recovery, err = passcode.Valid(code); recovery {
// Deactivate 2FA if recovery code has been used. // Deactivate 2FA if recovery code has been used.
passcode, err = m.DeactivatePasscode() passcode, err = m.DeactivatePasscode()
} }

View File

@@ -144,7 +144,7 @@ func (m *Link) InvalidPassword(password string) bool {
return password != "" return password != ""
} }
return pw.IsWrong(password) return pw.Invalid(password)
} }
// Save updates the record in the database or inserts a new record if it does not already exist. // Save updates the record in the database or inserts a new record if it does not already exist.

View File

@@ -236,8 +236,8 @@ func (m *Passcode) GenerateCode() (code string, err error) {
return code, err return code, err
} }
// Verify checks if the passcode provided is valid. // Valid checks if the passcode provided is valid.
func (m *Passcode) Verify(code string) (valid bool, recovery bool, err error) { func (m *Passcode) Valid(code string) (valid bool, recovery bool, err error) {
// Validate arguments. // Validate arguments.
if m == nil { if m == nil {
return false, false, errors.New("passcode is nil") return false, false, errors.New("passcode is nil")

View File

@@ -318,7 +318,7 @@ func TestPasscode_Verify(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
valid, recoveryCode, err := m.Verify(code) valid, recoveryCode, err := m.Valid(code)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -337,7 +337,7 @@ func TestPasscode_Verify(t *testing.T) {
assert.Nil(t, m.VerifiedAt) assert.Nil(t, m.VerifiedAt)
valid, recoveryCode, err := m.Verify("123456") valid, recoveryCode, err := m.Valid("123456")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -356,7 +356,7 @@ func TestPasscode_Verify(t *testing.T) {
assert.Nil(t, m.VerifiedAt) assert.Nil(t, m.VerifiedAt)
valid, recoveryCode, err := m.Verify("111") valid, recoveryCode, err := m.Valid("111")
assert.Error(t, err) assert.Error(t, err)
assert.False(t, valid) assert.False(t, valid)
@@ -372,7 +372,7 @@ func TestPasscode_Verify(t *testing.T) {
assert.Nil(t, m.VerifiedAt) assert.Nil(t, m.VerifiedAt)
valid, recoveryCode, err := m.Verify("123") valid, recoveryCode, err := m.Valid("123")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -391,7 +391,7 @@ func TestPasscode_Verify(t *testing.T) {
assert.Nil(t, m.VerifiedAt) assert.Nil(t, m.VerifiedAt)
valid, recoveryCode, err := m.Verify("") valid, recoveryCode, err := m.Valid("")
assert.Error(t, err) assert.Error(t, err)
assert.False(t, valid) assert.False(t, valid)
@@ -407,7 +407,7 @@ func TestPasscode_Verify(t *testing.T) {
assert.Nil(t, m.VerifiedAt) assert.Nil(t, m.VerifiedAt)
valid, recoveryCode, err := m.Verify("123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891") valid, recoveryCode, err := m.Valid("123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891123456789112345678911234567891")
assert.Error(t, err) assert.Error(t, err)
assert.False(t, valid) assert.False(t, valid)
@@ -424,7 +424,7 @@ func TestPasscode_Verify(t *testing.T) {
assert.Nil(t, m.VerifiedAt) assert.Nil(t, m.VerifiedAt)
valid, recoveryCode, err := m.Verify("123456") valid, recoveryCode, err := m.Valid("123456")
assert.Error(t, err) assert.Error(t, err)
assert.False(t, valid) assert.False(t, valid)
@@ -454,7 +454,7 @@ func TestPasscode_Activate(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, _, err = m.Verify(code) _, _, err = m.Valid(code)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -1,11 +1,11 @@
package entity package entity
import ( import (
"fmt"
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
@@ -51,9 +51,9 @@ func (m *Password) SetPassword(pw string, allowHash bool) error {
// Check if password is too short or too long. // Check if password is too short or too long.
if len([]rune(pw)) < 1 { if len([]rune(pw)) < 1 {
return fmt.Errorf("password is too short") return authn.ErrPasswordTooShort
} else if len(pw) > txt.ClipPassword { } else if len(pw) > txt.ClipPassword {
return fmt.Errorf("password must have less than %d characters", txt.ClipPassword) return authn.ErrPasswordTooLong
} }
// Check if string already is a bcrypt hash. // Check if string already is a bcrypt hash.
@@ -73,14 +73,14 @@ func (m *Password) SetPassword(pw string, allowHash bool) error {
} }
} }
// IsValid checks if the password is correct. // Valid checks if the password is correct.
func (m *Password) IsValid(s string) bool { func (m *Password) Valid(s string) bool {
return !m.IsWrong(s) return !m.Invalid(s)
} }
// IsWrong checks if the specified password is incorrect. // Invalid checks if the specified password is incorrect.
func (m *Password) IsWrong(s string) bool { func (m *Password) Invalid(s string) bool {
if m.IsEmpty() { if m.Empty() {
// No password set. // No password set.
return true return true
} else if s = clean.Password(s); s == "" { } else if s = clean.Password(s); s == "" {
@@ -118,15 +118,15 @@ func FindPassword(uid string) *Password {
// Cost returns the hashing cost of the currently set password. // Cost returns the hashing cost of the currently set password.
func (m *Password) Cost() (int, error) { func (m *Password) Cost() (int, error) {
if m.IsEmpty() { if m.Empty() {
return 0, fmt.Errorf("password is empty") return 0, authn.ErrPasswordRequired
} }
return bcrypt.Cost([]byte(m.Hash)) return bcrypt.Cost([]byte(m.Hash))
} }
// IsEmpty returns true if no password is set. // Empty checks if a password has not been set yet.
func (m *Password) IsEmpty() bool { func (m *Password) Empty() bool {
return m.Hash == "" return m.Hash == ""
} }

View File

@@ -21,16 +21,16 @@ func TestPassword_SetPassword(t *testing.T) {
t.Run("Text", func(t *testing.T) { t.Run("Text", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "passwd", false) p := NewPassword("urrwaxd19ldtz68x", "passwd", false)
assert.Len(t, p.Hash, 60) assert.Len(t, p.Hash, 60)
assert.True(t, p.IsValid("passwd")) assert.True(t, p.Valid("passwd"))
assert.False(t, p.IsValid("other")) assert.False(t, p.Valid("other"))
if err := p.SetPassword("abcd", false); err != nil { if err := p.SetPassword("abcd", false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Len(t, p.Hash, 60) assert.Len(t, p.Hash, 60)
assert.True(t, p.IsValid("abcd")) assert.True(t, p.Valid("abcd"))
assert.False(t, p.IsValid("other")) assert.False(t, p.Valid("other"))
}) })
t.Run("Too long", func(t *testing.T) { t.Run("Too long", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "hgfttrgkncgdhfkbvuvygvbekdjbrtugbnljbtruhogtgbotuhblenbhoyuhntyyhngytohrpnehotyihniy", false) p := NewPassword("urrwaxd19ldtz68x", "hgfttrgkncgdhfkbvuvygvbekdjbrtugbnljbtruhogtgbotuhblenbhoyuhntyyhngytohrpnehotyihniy", false)
@@ -49,55 +49,55 @@ func TestPassword_SetPassword(t *testing.T) {
t.Run("Hash", func(t *testing.T) { t.Run("Hash", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "$2a$14$qCcNjxupSJV1gjhgdYxz8e9l0e0fTZosX0s0qhMK54IkI9YOyWLt2", true) p := NewPassword("urrwaxd19ldtz68x", "$2a$14$qCcNjxupSJV1gjhgdYxz8e9l0e0fTZosX0s0qhMK54IkI9YOyWLt2", true)
assert.Len(t, p.Hash, 60) assert.Len(t, p.Hash, 60)
assert.True(t, p.IsValid("photoprism")) assert.True(t, p.Valid("photoprism"))
assert.False(t, p.IsValid("$2a$14$qCcNjxupSJV1gjhgdYxz8e9l0e0fTZosX0s0qhMK54IkI9YOyWLt2")) assert.False(t, p.Valid("$2a$14$qCcNjxupSJV1gjhgdYxz8e9l0e0fTZosX0s0qhMK54IkI9YOyWLt2"))
assert.False(t, p.IsValid("other")) assert.False(t, p.Valid("other"))
}) })
} }
func TestPassword_IsValid(t *testing.T) { func TestPassword_Valid(t *testing.T) {
t.Run("EmptyHash", func(t *testing.T) { t.Run("EmptyHash", func(t *testing.T) {
p := Password{Hash: ""} p := Password{Hash: ""}
assert.True(t, p.IsEmpty()) assert.True(t, p.Empty())
assert.False(t, p.IsValid("")) assert.False(t, p.Valid(""))
}) })
t.Run("EmptyPassword", func(t *testing.T) { t.Run("EmptyPassword", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "", false) p := NewPassword("urrwaxd19ldtz68x", "", false)
assert.True(t, p.IsEmpty()) assert.True(t, p.Empty())
assert.False(t, p.IsValid("")) assert.False(t, p.Valid(""))
}) })
t.Run("ShortPassword", func(t *testing.T) { t.Run("ShortPassword", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "passwd", false) p := NewPassword("urrwaxd19ldtz68x", "passwd", false)
assert.True(t, p.IsValid("passwd")) assert.True(t, p.Valid("passwd"))
assert.False(t, p.IsValid("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G")) assert.False(t, p.Valid("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G"))
}) })
t.Run("LongPassword", func(t *testing.T) { t.Run("LongPassword", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "photoprism", false) p := NewPassword("urrwaxd19ldtz68x", "photoprism", false)
assert.True(t, p.IsValid("photoprism")) assert.True(t, p.Valid("photoprism"))
assert.False(t, p.IsValid("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G")) assert.False(t, p.Valid("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G"))
}) })
} }
func TestPassword_IsWrong(t *testing.T) { func TestPassword_Invalid(t *testing.T) {
t.Run("EmptyHash", func(t *testing.T) { t.Run("EmptyHash", func(t *testing.T) {
p := Password{Hash: ""} p := Password{Hash: ""}
assert.True(t, p.IsEmpty()) assert.True(t, p.Empty())
assert.True(t, p.IsWrong("")) assert.True(t, p.Invalid(""))
}) })
t.Run("EmptyPassword", func(t *testing.T) { t.Run("EmptyPassword", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "", false) p := NewPassword("urrwaxd19ldtz68x", "", false)
assert.True(t, p.IsEmpty()) assert.True(t, p.Empty())
assert.True(t, p.IsWrong("")) assert.True(t, p.Invalid(""))
}) })
t.Run("ShortPassword", func(t *testing.T) { t.Run("ShortPassword", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "passwd", false) p := NewPassword("urrwaxd19ldtz68x", "passwd", false)
assert.True(t, p.IsWrong("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G")) assert.True(t, p.Invalid("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G"))
assert.False(t, p.IsWrong("passwd")) assert.False(t, p.Invalid("passwd"))
}) })
t.Run("LongPassword", func(t *testing.T) { t.Run("LongPassword", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "photoprism", false) p := NewPassword("urrwaxd19ldtz68x", "photoprism", false)
assert.True(t, p.IsWrong("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G")) assert.True(t, p.Invalid("$2a$14$p3HKuLvrTuePG/pjXLJQseUnSeAVeVO2cy4b0.34KXsLPK8lkI92G"))
assert.False(t, p.IsWrong("photoprism")) assert.False(t, p.Invalid("photoprism"))
}) })
} }
@@ -130,14 +130,14 @@ func TestFindPassword(t *testing.T) {
if p := FindPassword("uqxetse3cy5eo9z2"); p == nil { if p := FindPassword("uqxetse3cy5eo9z2"); p == nil {
t.Fatal("password not found") t.Fatal("password not found")
} else { } else {
assert.False(t, p.IsWrong("Alice123!")) assert.False(t, p.Invalid("Alice123!"))
} }
}) })
t.Run("Bob", func(t *testing.T) { t.Run("Bob", func(t *testing.T) {
if p := FindPassword("uqxc08w3d0ej2283"); p == nil { if p := FindPassword("uqxc08w3d0ej2283"); p == nil {
t.Fatal("password not found") t.Fatal("password not found")
} else { } else {
assert.False(t, p.IsWrong("Bobbob123!")) assert.False(t, p.Invalid("Bobbob123!"))
} }
}) })
} }
@@ -176,10 +176,10 @@ func TestPassword_String(t *testing.T) {
func TestPassword_IsEmpty(t *testing.T) { func TestPassword_IsEmpty(t *testing.T) {
t.Run("False", func(t *testing.T) { t.Run("False", func(t *testing.T) {
p := NewPassword("urrwaxd19ldtz68x", "lkjhgtyu", false) p := NewPassword("urrwaxd19ldtz68x", "lkjhgtyu", false)
assert.False(t, p.IsEmpty()) assert.False(t, p.Empty())
}) })
t.Run("True", func(t *testing.T) { t.Run("True", func(t *testing.T) {
p := Password{} p := Password{}
assert.True(t, p.IsEmpty()) assert.True(t, p.Empty())
}) })
} }

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/geo" "github.com/photoprism/photoprism/pkg/geo"
@@ -148,7 +149,7 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
// Visitors and other restricted users can only access shared content. // Visitors and other restricted users can only access shared content.
if f.Scope != "" && !sess.HasShare(f.Scope) && (sess.User().HasSharedAccessOnly(acl.ResourcePhotos) || sess.NotRegistered()) || if f.Scope != "" && !sess.HasShare(f.Scope) && (sess.User().HasSharedAccessOnly(acl.ResourcePhotos) || sess.NotRegistered()) ||
f.Scope == "" && acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) { f.Scope == "" && acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) {
event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePhotos), aclRole) event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", authn.Denied}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePhotos), aclRole)
return PhotoResults{}, 0, ErrForbidden return PhotoResults{}, 0, ErrForbidden
} }

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/geo" "github.com/photoprism/photoprism/pkg/geo"
@@ -131,7 +132,7 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
// Visitors and other restricted users can only access shared content. // Visitors and other restricted users can only access shared content.
if f.Scope != "" && !sess.HasShare(f.Scope) && (sess.User().HasSharedAccessOnly(acl.ResourcePlaces) || sess.NotRegistered()) || if f.Scope != "" && !sess.HasShare(f.Scope) && (sess.User().HasSharedAccessOnly(acl.ResourcePlaces) || sess.NotRegistered()) ||
f.Scope == "" && acl.Rules.Deny(acl.ResourcePlaces, aclRole, acl.ActionSearch) { f.Scope == "" && acl.Rules.Deny(acl.ResourcePlaces, aclRole, acl.ActionSearch) {
event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePlaces), aclRole) event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", authn.Denied}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePlaces), aclRole)
return GeoResults{}, ErrForbidden return GeoResults{}, ErrForbidden
} }

View File

@@ -17,6 +17,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/server/limiter" "github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/header" "github.com/photoprism/photoprism/pkg/header"
@@ -103,7 +104,7 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
return return
} }
event.AuditErr([]string{clientIp, "access webdav as %s with authorization granted to %s", "denied"}, clean.Log(username), clean.Log(user.Username())) event.AuditErr([]string{clientIp, "access webdav as %s with authorization granted to %s", authn.Denied}, clean.Log(username), clean.Log(user.Username()))
limiter.Auth.Reserve(clientIp) limiter.Auth.Reserve(clientIp)
WebDAVAbortUnauthorized(c) WebDAVAbortUnauthorized(c)
return return
@@ -111,31 +112,31 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
// Ignore and try basic auth next. // Ignore and try basic auth next.
} else if !sess.HasUser() || user == nil { } else if !sess.HasUser() || user == nil {
// Log error if session does not belong to an authorized user account. // Log error if session does not belong to an authorized user account.
event.AuditErr([]string{clientIp, "session %s", "access webdav without user account", "denied"}, sess.RefID) event.AuditErr([]string{clientIp, "session %s", "access webdav without user account", authn.Denied}, sess.RefID)
WebDAVAbortUnauthorized(c) WebDAVAbortUnauthorized(c)
return return
} else if sess.IsClient() && sess.ScopeExcludes(acl.ResourceWebDAV, nil) { } else if sess.IsClient() && sess.InsufficientScope(acl.ResourceWebDAV, nil) {
// Log error if the client is allowed to access webdav based on its scope. // Log error if the client is allowed to access webdav based on its scope.
message := "denied" message := authn.ErrInsufficientScope.Error()
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), 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) WebDAVAbortUnauthorized(c)
return return
} else if !user.CanUseWebDAV() { } else if !user.CanUseWebDAV() {
// Log warning if WebDAV is disabled for this account. // Log warning if WebDAV is disabled for this account.
message := "webdav access is disabled" message := authn.ErrWebDAVAccessDisabled.Error()
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), 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) WebDAVAbortUnauthorized(c)
return return
} else if username != "" && !strings.EqualFold(clean.Username(username), user.Username()) { } else if username != "" && !strings.EqualFold(clean.Username(username), user.Username()) {
// Log warning if WebDAV is disabled for this account. // Log warning if WebDAV is disabled for this account.
message := "basic auth username does not match" message := authn.ErrBasicAuthDoesNotMatch.Error()
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), 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()))
limiter.Auth.Reserve(clientIp) limiter.Auth.Reserve(clientIp)
WebDAVAbortUnauthorized(c) WebDAVAbortUnauthorized(c)
return return
} else if err := fs.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath())); err != nil { } else if err := fs.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath())); err != nil {
// Log warning if upload path could not be created. // Log warning if upload path could not be created.
message := "failed to create user upload path" message := authn.ErrFailedToCreateUploadPath.Error()
event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), 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) WebDAVAbortServerError(c)
return return
@@ -177,24 +178,24 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
// Check credentials and authorization. // Check credentials and authorization.
if user, _, _, err := entity.Auth(f, nil, c); err != nil { if user, _, _, err := entity.Auth(f, nil, c); err != nil {
// Abort if authentication has failed. // Abort if authentication has failed.
message := err.Error() message := authn.ErrInvalidCredentials.Error()
limiter.Login.Reserve(clientIp) limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username)) event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message) event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
} else if user == nil { } else if user == nil {
// Abort if account was not found. // Abort if account was not found.
message := "account not found" message := authn.ErrAccountNotFound.Error()
limiter.Login.Reserve(clientIp) limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username)) event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message) event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
} else if !user.CanUseWebDAV() { } else if !user.CanUseWebDAV() {
// Abort if WebDAV is disabled for this account. // Abort if WebDAV is disabled for this account.
message := "webdav access is disabled" message := authn.ErrWebDAVAccessDisabled.Error()
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username)) event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message) event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
} else if err = fs.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath())); err != nil { } else if err = fs.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath())); err != nil {
// Abort if upload path could not be created. // Abort if upload path could not be created.
message := "failed to create user upload path" message := authn.ErrFailedToCreateUploadPath.Error()
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username)) event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message) event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
WebDAVAbortServerError(c) WebDAVAbortServerError(c)

View File

@@ -170,7 +170,7 @@ func TestWebDAVAuthSession(t *testing.T) {
assert.True(t, sess.HasUser()) assert.True(t, sess.HasUser())
assert.Equal(t, user.UserUID, sess.UserUID) assert.Equal(t, user.UserUID, sess.UserUID)
assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID) assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID)
assert.True(t, sess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionView})) assert.True(t, sess.ValidateScope(acl.ResourceWebDAV, acl.Permissions{acl.ActionView}))
assert.False(t, cached) assert.False(t, cached)
assert.Equal(t, s.ID, sid) assert.Equal(t, s.ID, sid)
@@ -222,7 +222,7 @@ func TestWebDAVAuthSession(t *testing.T) {
assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, user.UserUID) assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, user.UserUID)
assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID) assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID)
assert.True(t, user.CanUseWebDAV()) assert.True(t, user.CanUseWebDAV())
assert.False(t, sess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionView})) assert.False(t, sess.ValidateScope(acl.ResourceWebDAV, acl.Permissions{acl.ActionView}))
// WebDAVAuthSession should not set a status code or any headers. // WebDAVAuthSession should not set a status code or any headers.
assert.Equal(t, http.StatusOK, c.Writer.Status()) assert.Equal(t, http.StatusOK, c.Writer.Status())

7
pkg/authn/const.go Normal file
View File

@@ -0,0 +1,7 @@
package authn
// Generic status messages for authentication and authorization:
const (
Denied = "denied"
Granted = "granted"
)

View File

@@ -2,8 +2,30 @@ package authn
import ( import (
"errors" "errors"
"fmt"
"github.com/photoprism/photoprism/pkg/txt"
) )
// Generic error messages for authentication and authorization:
var (
ErrUnauthorized = errors.New("unauthorized")
ErrAccountAlreadyExists = errors.New("account already exists")
ErrAccountNotFound = errors.New("account not found")
ErrAccountDisabled = errors.New("account disabled")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidShareToken = errors.New("invalid share token")
ErrInsufficientScope = errors.New("insufficient scope")
ErrDisabledInPublicMode = errors.New("disabled in public mode")
)
// Username-related error messages:
var (
ErrUsernameRequired = errors.New("username required")
ErrInvalidUsername = errors.New("invalid username")
)
// Passcode-related error messages:
var ( var (
ErrPasscodeRequired = errors.New("passcode required") ErrPasscodeRequired = errors.New("passcode required")
ErrPasscodeNotSetUp = errors.New("passcode required, but not set up") ErrPasscodeNotSetUp = errors.New("passcode required, but not set up")
@@ -14,8 +36,20 @@ var (
ErrInvalidPasscodeFormat = errors.New("invalid passcode format") ErrInvalidPasscodeFormat = errors.New("invalid passcode format")
ErrInvalidPasscodeKey = errors.New("invalid passcode key") ErrInvalidPasscodeKey = errors.New("invalid passcode key")
ErrInvalidPasscodeType = errors.New("invalid passcode type") ErrInvalidPasscodeType = errors.New("invalid passcode type")
ErrPasswordRequired = errors.New("password required") )
ErrInvalidPassword = errors.New("invalid password")
ErrInvalidPasswordFormat = errors.New("invalid password format") // Password-related error messages:
ErrInvalidCredentials = errors.New("invalid credentials") var (
ErrInvalidPassword = errors.New("invalid password")
ErrPasswordRequired = errors.New("password required")
ErrPasswordTooShort = errors.New("password is too short")
ErrPasswordTooLong = errors.New(fmt.Sprintf("password must have less than %d characters", txt.ClipPassword))
ErrPasswordsDoNotMatch = errors.New("passwords do not match")
)
// WebDAV-related error messages:
var (
ErrWebDAVAccessDisabled = errors.New("webdav access is disabled")
ErrFailedToCreateUploadPath = errors.New("failed to create upload path")
ErrBasicAuthDoesNotMatch = errors.New("basic auth username does not match")
) )