Account: Allow OIDC and LDAP users with password to use 2FA #782 #808

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2024-07-10 17:24:02 +02:00
parent 8f22e86f84
commit 9969590472
10 changed files with 120 additions and 33 deletions

View File

@@ -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;
} }

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
} }

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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")
) )

View File

@@ -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),
} }

View File

@@ -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())