mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-27 13:13:32 +08:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -43,6 +43,7 @@ export default class Session {
|
|||||||
this.storage_key = "sessionStorage";
|
this.storage_key = "sessionStorage";
|
||||||
this.auth = false;
|
this.auth = false;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.provider = "";
|
||||||
this.user = new User(false);
|
this.user = new User(false);
|
||||||
this.data = null;
|
this.data = null;
|
||||||
|
|
||||||
@@ -64,6 +65,11 @@ export default class Session {
|
|||||||
if (userJson !== "undefined") {
|
if (userJson !== "undefined") {
|
||||||
this.user = new User(JSON.parse(userJson));
|
this.user = new User(JSON.parse(userJson));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const provider = this.storage.getItem("provider");
|
||||||
|
if (provider !== null) {
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated?
|
// Authenticated?
|
||||||
@@ -200,9 +206,23 @@ export default class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getProvider() {
|
getProvider() {
|
||||||
|
if (!this.provider) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
return this.provider;
|
return this.provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasPassword() {
|
||||||
|
switch (this.getProvider()) {
|
||||||
|
case "local":
|
||||||
|
case "ldap":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hasProvider() {
|
hasProvider() {
|
||||||
return !!this.provider;
|
return !!this.provider;
|
||||||
}
|
}
|
||||||
|
@@ -261,6 +261,7 @@ export default {
|
|||||||
recoveryCodeCopied: false,
|
recoveryCodeCopied: false,
|
||||||
password: "",
|
password: "",
|
||||||
showPassword: false,
|
showPassword: false,
|
||||||
|
session: this.$session,
|
||||||
minLength: this.$config.get("passwordLength"),
|
minLength: this.$config.get("passwordLength"),
|
||||||
maxLength: 72,
|
maxLength: 72,
|
||||||
rtl: this.$rtl,
|
rtl: this.$rtl,
|
||||||
@@ -269,7 +270,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
page() {
|
page() {
|
||||||
if (this.model?.AuthProvider !== "default" && this.model?.AuthProvider !== "local" && this.model?.AuthProvider !== "ldap") {
|
if (!this.session.hasPassword()) {
|
||||||
return "not_available";
|
return "not_available";
|
||||||
} else if (this.model?.AuthMethod === "2fa") {
|
} else if (this.model?.AuthMethod === "2fa") {
|
||||||
return "deactivate";
|
return "deactivate";
|
||||||
|
@@ -289,7 +289,7 @@ export class User extends RestModel {
|
|||||||
}).then((response) => Promise.resolve(response.data));
|
}).then((response) => Promise.resolve(response.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
disablePasscodeSetup() {
|
disablePasscodeSetup(hasPassword) {
|
||||||
if (!this.Name || !this.CanLogin || this.ID < 1) {
|
if (!this.Name || !this.CanLogin || this.ID < 1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -297,6 +297,8 @@ export class User extends RestModel {
|
|||||||
switch (this.AuthProvider) {
|
switch (this.AuthProvider) {
|
||||||
case "":
|
case "":
|
||||||
case "default":
|
case "default":
|
||||||
|
case "oidc":
|
||||||
|
return !hasPassword;
|
||||||
case "local":
|
case "local":
|
||||||
case "ldap":
|
case "ldap":
|
||||||
return false;
|
return false;
|
||||||
|
@@ -183,16 +183,16 @@
|
|||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-layout wrap align-top>
|
<v-layout wrap align-top>
|
||||||
<v-flex xs12 sm6 class="pa-2">
|
<v-flex xs12 sm6 class="pa-2">
|
||||||
<v-btn block depressed color="secondary-light" class="action-change-password compact" :disabled="isPublic || isDemo || user.Name === '' || provider !== 'local'" @click.stop="showDialog('password')">
|
<v-btn block depressed color="secondary-light" class="action-change-password compact" :disabled="isPublic || isDemo || user.Name === '' || getProvider() !== 'local'" @click.stop="showDialog('password')">
|
||||||
<translate>Change Password</translate>
|
<translate>Change Password</translate>
|
||||||
<v-icon :right="!rtl" :left="rtl" dark>lock</v-icon>
|
<v-icon :right="!rtl" :left="rtl" dark>lock</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-flex>
|
</v-flex>
|
||||||
<v-flex xs12 sm6 class="pa-2">
|
<v-flex xs12 sm6 class="pa-2">
|
||||||
<v-btn block depressed color="secondary-light" class="action-passcode-dialog compact" :disabled="isPublic || isDemo || user.disablePasscodeSetup()" @click.stop="showDialog('passcode')">
|
<v-btn block depressed color="secondary-light" class="action-passcode-dialog compact" :disabled="isPublic || isDemo || user.disablePasscodeSetup(session.hasPassword())" @click.stop="showDialog('passcode')">
|
||||||
<translate>2-Factor Authentication</translate>
|
<translate>2-Factor Authentication</translate>
|
||||||
<v-icon v-if="user.AuthMethod === '2fa'" :right="!rtl" :left="rtl" dark>gpp_good</v-icon>
|
<v-icon v-if="user.AuthMethod === '2fa'" :right="!rtl" :left="rtl" dark>gpp_good</v-icon>
|
||||||
<v-icon v-else-if="user.disablePasscodeSetup()" :right="!rtl" :left="rtl" dark>shield</v-icon>
|
<v-icon v-else-if="user.disablePasscodeSetup(session.hasPassword())" :right="!rtl" :left="rtl" dark>shield</v-icon>
|
||||||
<v-icon v-else :right="!rtl" :left="rtl" dark>gpp_maybe</v-icon>
|
<v-icon v-else :right="!rtl" :left="rtl" dark>gpp_maybe</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-flex>
|
</v-flex>
|
||||||
@@ -371,7 +371,7 @@ export default {
|
|||||||
rtl: this.$rtl,
|
rtl: this.$rtl,
|
||||||
user: user,
|
user: user,
|
||||||
countries: countries,
|
countries: countries,
|
||||||
provider: this.$session.provider ? this.$session.provider : user.AuthProvider,
|
session: this.$session,
|
||||||
dialog: {
|
dialog: {
|
||||||
apps: false,
|
apps: false,
|
||||||
passcode: false,
|
passcode: false,
|
||||||
@@ -404,6 +404,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getProvider() {
|
||||||
|
return this.$session.provider ? this.$session.provider : this.user.AuthProvider;
|
||||||
|
},
|
||||||
showDialog(name) {
|
showDialog(name) {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return;
|
return;
|
||||||
|
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize/english"
|
"github.com/dustin/go-humanize/english"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -145,14 +146,14 @@ func OAuthToken(router *gin.RouterGroup) {
|
|||||||
event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.Denied})
|
event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.Denied})
|
||||||
AbortInvalidCredentials(c)
|
AbortInvalidCredentials(c)
|
||||||
return
|
return
|
||||||
} else if !authUser.Equal(s.User()) {
|
} else if authMethod.Is(authn.Method2FA) && errors.Is(authErr, authn.ErrPasscodeRequired) {
|
||||||
event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrInvalidUsername.Error()})
|
// Ok.
|
||||||
|
} else if authErr != nil {
|
||||||
|
event.AuditErr([]string{clientIp, "oauth2", actor, action, "%s"}, strings.ToLower(clean.Error(authErr)))
|
||||||
AbortInvalidCredentials(c)
|
AbortInvalidCredentials(c)
|
||||||
return
|
return
|
||||||
} else if authMethod.Is(authn.Method2FA) && errors.Is(authErr, authn.ErrPasscodeRequired) {
|
} else if !authUser.Equal(s.User()) {
|
||||||
// Ignore.
|
event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrUserDoesNotMatch.Error()})
|
||||||
} else if authErr != nil {
|
|
||||||
event.AuditErr([]string{clientIp, "oauth2", actor, action, "%s"}, clean.Error(authErr))
|
|
||||||
AbortInvalidCredentials(c)
|
AbortInvalidCredentials(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize/english"
|
"github.com/dustin/go-humanize/english"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -31,21 +33,22 @@ func CreateUserPasscode(router *gin.RouterGroup) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get client IP address for logs and rate limiting checks.
|
||||||
|
clientIp := ClientIP(c)
|
||||||
|
|
||||||
// Check request rate limit.
|
// Check request rate limit.
|
||||||
r := limiter.Login.Request(ClientIP(c))
|
r := limiter.Login.Request(clientIp)
|
||||||
|
|
||||||
if r.Reject() {
|
if r.Reject() {
|
||||||
limiter.AbortJSON(c)
|
limiter.AbortJSON(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password if user authenticates with a local account.
|
// Check user password and abort if invalid.
|
||||||
switch user.Provider() {
|
if code, msg, err := checkUserPasscodePassword(c, user, frm.Password); err != nil {
|
||||||
case authn.ProviderDefault, authn.ProviderLocal:
|
event.AuditErr([]string{clientIp, "session %s", authn.Users, user.UserName, authn.ErrPasscodeGenerateFailed.Error(), strings.ToLower(clean.Error(err))}, s.RefID)
|
||||||
if user.InvalidPassword(frm.Password) {
|
Abort(c, code, msg)
|
||||||
Abort(c, http.StatusForbidden, i18n.ErrInvalidPassword)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the reserved request rate limit tokens after successful authentication.
|
// Return the reserved request rate limit tokens after successful authentication.
|
||||||
@@ -169,22 +172,22 @@ func DeactivateUserPasscode(router *gin.RouterGroup) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get client IP address for logs and rate limiting checks.
|
||||||
|
clientIp := ClientIP(c)
|
||||||
|
|
||||||
// Check request rate limit.
|
// Check request rate limit.
|
||||||
r := limiter.Login.Request(ClientIP(c))
|
r := limiter.Login.Request(clientIp)
|
||||||
|
|
||||||
if r.Reject() {
|
if r.Reject() {
|
||||||
limiter.AbortJSON(c)
|
limiter.AbortJSON(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password if user authenticates with a local account.
|
// Check user password and abort if invalid.
|
||||||
switch user.Provider() {
|
if code, msg, err := checkUserPasscodePassword(c, user, frm.Password); err != nil {
|
||||||
case authn.ProviderDefault, authn.ProviderLocal:
|
event.AuditErr([]string{clientIp, "session %s", authn.Users, user.UserName, authn.ErrPasscodeDeactivationFailed.Error(), strings.ToLower(clean.Error(err))}, s.RefID)
|
||||||
// Check password and abort if invalid.
|
Abort(c, code, msg)
|
||||||
if user.InvalidPassword(frm.Password) {
|
return
|
||||||
Abort(c, http.StatusForbidden, i18n.ErrInvalidPassword)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the reserved request rate limit tokens after successful authentication.
|
// Return the reserved request rate limit tokens after successful authentication.
|
||||||
@@ -239,7 +242,7 @@ func checkUserPasscodeAuth(c *gin.Context, action acl.Permission) (*entity.Sessi
|
|||||||
user := s.User()
|
user := s.User()
|
||||||
|
|
||||||
// Regular users can only set up a passcode for their own account.
|
// Regular users can only set up a passcode for their own account.
|
||||||
if user.UserUID != uid {
|
if user.UserUID != uid || !user.CanLogIn() {
|
||||||
AbortForbidden(c)
|
AbortForbidden(c)
|
||||||
return s, nil, nil, authn.ErrUnauthorized
|
return s, nil, nil, authn.ErrUnauthorized
|
||||||
}
|
}
|
||||||
@@ -263,3 +266,56 @@ func checkUserPasscodeAuth(c *gin.Context, action acl.Permission) (*entity.Sessi
|
|||||||
|
|
||||||
return s, user, frm, nil
|
return s, user, frm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkUserPasscodePassword checks if the specified password is valid.
|
||||||
|
func checkUserPasscodePassword(c *gin.Context, user *entity.User, password string) (code int, msg i18n.Message, err error) {
|
||||||
|
// Set result defaults.
|
||||||
|
code = http.StatusForbidden
|
||||||
|
msg = i18n.ErrInvalidPassword
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
return code, msg, authn.ErrUserRequired
|
||||||
|
} else if c == nil {
|
||||||
|
return code, msg, authn.ErrContextRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
username := user.Username()
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
return code, msg, authn.ErrUsernameRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
switch user.Provider() {
|
||||||
|
// Check local account password.
|
||||||
|
case authn.ProviderLocal:
|
||||||
|
if user.InvalidPassword(password) {
|
||||||
|
return code, msg, authn.ErrInvalidPassword
|
||||||
|
}
|
||||||
|
// Use generic authentication check.
|
||||||
|
default:
|
||||||
|
// Create user login form.
|
||||||
|
f := form.Login{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user login credentials are valid.
|
||||||
|
if authUser, provider, method, authErr := entity.Auth(f, nil, c); method == authn.Method2FA && errors.Is(authErr, authn.ErrPasscodeRequired) {
|
||||||
|
return http.StatusOK, i18n.MsgVerified, nil
|
||||||
|
} else if authErr != nil {
|
||||||
|
// Abort if authentication has failed otherwise.
|
||||||
|
return code, msg, authErr
|
||||||
|
} else if authUser == nil {
|
||||||
|
// Abort if account was not found.
|
||||||
|
return code, msg, authn.ErrAccountNotFound
|
||||||
|
} else if !authUser.Equal(user) {
|
||||||
|
// Abort if user accounts do not match.
|
||||||
|
return code, msg, authn.ErrUserDoesNotMatch
|
||||||
|
} else if !provider.SupportsPasscodeAuthentication() || method != authn.MethodDefault {
|
||||||
|
// Abort if e.g. an app password was provided.
|
||||||
|
return code, msg, authn.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, i18n.MsgVerified, nil
|
||||||
|
}
|
||||||
|
@@ -109,7 +109,7 @@ func AuthLocal(user *User, f form.Login, s *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 := authn.ErrInvalidUsername.Error()
|
message := authn.ErrInvalidUser.Error()
|
||||||
|
|
||||||
if s != nil {
|
if s != nil {
|
||||||
event.AuditErr([]string{clientIp, "session %s", "login as %s", "app password", message}, s.RefID, clean.LogQuote(username))
|
event.AuditErr([]string{clientIp, "session %s", "login as %s", "app password", message}, s.RefID, clean.LogQuote(username))
|
||||||
|
@@ -24,6 +24,7 @@ var (
|
|||||||
ErrInsufficientScope = errors.New("insufficient scope")
|
ErrInsufficientScope = errors.New("insufficient scope")
|
||||||
ErrNameRequired = errors.New("name required")
|
ErrNameRequired = errors.New("name required")
|
||||||
ErrScopeRequired = errors.New("scope required")
|
ErrScopeRequired = errors.New("scope required")
|
||||||
|
ErrContextRequired = errors.New("context required")
|
||||||
ErrDisabledInPublicMode = errors.New("disabled in public mode")
|
ErrDisabledInPublicMode = errors.New("disabled in public mode")
|
||||||
ErrAuthenticationDisabled = errors.New("authentication disabled")
|
ErrAuthenticationDisabled = errors.New("authentication disabled")
|
||||||
ErrRateLimitExceeded = errors.New("rate limit exceeded")
|
ErrRateLimitExceeded = errors.New("rate limit exceeded")
|
||||||
@@ -47,9 +48,11 @@ var (
|
|||||||
|
|
||||||
// User-related error messages:
|
// User-related error messages:
|
||||||
var (
|
var (
|
||||||
|
ErrUserRequired = errors.New("user required")
|
||||||
ErrUsernameRequired = errors.New("username required")
|
ErrUsernameRequired = errors.New("username required")
|
||||||
ErrUsernameRequiredToRegister = errors.New("username required to register")
|
ErrUsernameRequiredToRegister = errors.New("username required to register")
|
||||||
ErrInvalidUsername = errors.New("invalid username")
|
ErrInvalidUser = errors.New("invalid user")
|
||||||
|
ErrUserDoesNotMatch = errors.New("user does not match")
|
||||||
ErrUsernameDoesNotMatch = errors.New("specified username does not match")
|
ErrUsernameDoesNotMatch = errors.New("specified username does not match")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -49,6 +49,7 @@ var PasswordProviders = list.List{
|
|||||||
var PasscodeProviders = list.List{
|
var PasscodeProviders = list.List{
|
||||||
string(ProviderDefault),
|
string(ProviderDefault),
|
||||||
string(ProviderLocal),
|
string(ProviderLocal),
|
||||||
|
string(ProviderOIDC),
|
||||||
string(ProviderLDAP),
|
string(ProviderLDAP),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -65,7 +65,7 @@ func TestProviderType_IsLocal(t *testing.T) {
|
|||||||
|
|
||||||
func TestProviderType_SupportsPasscode(t *testing.T) {
|
func TestProviderType_SupportsPasscode(t *testing.T) {
|
||||||
assert.True(t, ProviderLocal.SupportsPasscodeAuthentication())
|
assert.True(t, ProviderLocal.SupportsPasscodeAuthentication())
|
||||||
assert.False(t, ProviderOIDC.SupportsPasscodeAuthentication())
|
assert.True(t, ProviderOIDC.SupportsPasscodeAuthentication())
|
||||||
assert.True(t, ProviderLDAP.SupportsPasscodeAuthentication())
|
assert.True(t, ProviderLDAP.SupportsPasscodeAuthentication())
|
||||||
assert.False(t, ProviderClient.SupportsPasscodeAuthentication())
|
assert.False(t, ProviderClient.SupportsPasscodeAuthentication())
|
||||||
assert.False(t, ProviderApplication.SupportsPasscodeAuthentication())
|
assert.False(t, ProviderApplication.SupportsPasscodeAuthentication())
|
||||||
|
Reference in New Issue
Block a user