mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
OIDC: Improve CLI commands and add AuthIssuer to users and sessions #782
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-07-08 14:23+0000\n"
|
||||
"POT-Creation-Date: 2024-07-09 06:08+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -49,9 +49,12 @@ services:
|
||||
## OpenID Connect (pre-configured for local tests):
|
||||
## see https://keycloak.localssl.dev/realms/master/.well-known/openid-configuration
|
||||
PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/realms/master"
|
||||
PHOTOPRISM_OIDC_INSECURE: "true"
|
||||
PHOTOPRISM_OIDC_CLIENT: "photoprism-develop"
|
||||
PHOTOPRISM_OIDC_SECRET: "9d8351a0-ca01-4556-9c37-85eb634869b9"
|
||||
PHOTOPRISM_OIDC_PROVIDER: "Keycloak"
|
||||
PHOTOPRISM_OIDC_REGISTER: "true"
|
||||
PHOTOPRISM_OIDC_WEBDAV: "true"
|
||||
PHOTOPRISM_DISABLE_OIDC: "false"
|
||||
## LDAP Authentication (pre-configured for local tests):
|
||||
PHOTOPRISM_LDAP_URI: "ldap://dummy-ldap:389"
|
||||
PHOTOPRISM_LDAP_INSECURE: "true"
|
||||
|
||||
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@@ -6467,9 +6467,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.4.818",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.818.tgz",
|
||||
"integrity": "sha512-eGvIk2V0dGImV9gWLq8fDfTTsCAeMDwZqEPMr+jMInxZdnp9Us8UpovYpRCf9NQ7VOFgrN2doNSgvISbsbNpxA==",
|
||||
"version": "1.4.819",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.819.tgz",
|
||||
"integrity": "sha512-8RwI6gKUokbHWcN3iRij/qpvf/wCbIVY5slODi85werwqUQwpFXM+dvUBND93Qh7SB0pW3Hlq3/wZsqQ3M9Jaw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
@@ -7582,9 +7582,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
|
||||
"integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
|
||||
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
@@ -8099,9 +8099,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/flow-remove-types": {
|
||||
"version": "2.239.0",
|
||||
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.239.0.tgz",
|
||||
"integrity": "sha512-IBFfuqEe46Iegl4FwSq21cy3MndTTzc6yWboK+CcDyP2wyoMJIy2wbT+FJbissh0z9rRDavv5nNVUqKkty9KOQ==",
|
||||
"version": "2.239.1",
|
||||
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.239.1.tgz",
|
||||
"integrity": "sha512-Kam48LEA8DGmyG06fsbUA7sXhRppU6Jc903AvCChPfPmiTDAXW7xqWpYsbKFKuypuvc9BJI3tznFK8MI1Ho6oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hermes-parser": "0.22.0",
|
||||
@@ -9672,15 +9672,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.1.tgz",
|
||||
"integrity": "sha512-U23pQPDnmYybVkYjObcuYMk43VRlMLLqLI+RdZy8s8WV8WsxO9SnqSroKaluuvcNOdCAlauKszDwd+umbot5Mg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.2.tgz",
|
||||
"integrity": "sha512-qH3nOSj8q/8+Eg8LUPOq3C+6HWkpUioIjDsq1+D4zY91oZvpPttw8GwtF1nReRYKXl+1AORyFqtm2f5Q1SB6/Q==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "14 >=14.21 || 16 >=16.20 || >=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -10676,9 +10676,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib/node_modules/glob": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.3.tgz",
|
||||
"integrity": "sha512-Q38SGlYRpVtDBPSWEylRyctn7uDeTp4NQERTLiCT1FqA9JXPYWqAVmQU6qh4r/zMM5ehxTcbaO8EjhWnvEhmyg==",
|
||||
"version": "10.4.4",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.4.tgz",
|
||||
"integrity": "sha512-XsOKvHsu38Xe19ZQupE6N/HENeHQBA05o3hV8labZZT2zYDg1+emxWHnc/Bm9AcCMPXfD6jt+QC7zC5JSFyumw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
@@ -10692,7 +10692,7 @@
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "14 >=14.21 || 16 >=16.20 || 18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -10714,9 +10714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib/node_modules/rimraf": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.8.tgz",
|
||||
"integrity": "sha512-XSh0V2/yNhDEi8HwdIefD8MLgs4LQXPag/nEJWs3YUc3Upn+UHa1GyIkEg9xSSNt7HnkO5FjTvmcRzgf+8UZuw==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.9.tgz",
|
||||
"integrity": "sha512-3i7b8OcswU6CpU8Ej89quJD4O98id7TtVM5U4Mybh84zQXdrFmDLouWBEEaD/QfO3gDDfH+AGFCGsR7kngzQnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^10.3.7"
|
||||
@@ -10725,7 +10725,7 @@
|
||||
"rimraf": "dist/esm/bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "14 >=14.20 || 16 >=16.20 || >=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -11491,12 +11491,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.0.tgz",
|
||||
"integrity": "sha512-bfJaPTuEiTYBu+ulDaeQ0F+uLmlfFkMgXj4cbwfuMSjgObGMzb55FMMbDvbRU0fAHZ4sLGkz2mKwcMg8Dvm8Ww==",
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.2.tgz",
|
||||
"integrity": "sha512-voV4dDrdVZVNz84n39LFKDaRzfwhdzJ7akpyXfTMxCgRUp07U3lcJUXRlhTKP17rgt09sUzLi5iCitpEAr+6ug==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "14 || 16 || 18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
|
||||
@@ -43,7 +43,7 @@ export class Session extends RestModel {
|
||||
ClientName: "",
|
||||
AuthProvider: "",
|
||||
AuthMethod: "",
|
||||
AuthDomain: "",
|
||||
AuthIssuer: "",
|
||||
AuthID: "",
|
||||
AuthScope: "",
|
||||
GrantType: "",
|
||||
|
||||
@@ -40,6 +40,7 @@ export class User extends RestModel {
|
||||
UUID: "",
|
||||
AuthProvider: "",
|
||||
AuthMethod: "",
|
||||
AuthIssuer: "",
|
||||
AuthID: "",
|
||||
Name: "",
|
||||
DisplayName: "",
|
||||
@@ -212,6 +213,10 @@ export class User extends RestModel {
|
||||
return this.AuthProvider && this.AuthProvider === "ldap";
|
||||
}
|
||||
|
||||
requiresPassword() {
|
||||
return !this.AuthProvider || this.AuthProvider === "default" || this.AuthProvider === "local";
|
||||
}
|
||||
|
||||
authInfo() {
|
||||
if (!this || !this.AuthProvider) {
|
||||
return $gettext("Default");
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<v-flex xs12 class="px-2 py-1">
|
||||
<v-text-field
|
||||
id="one-time-code"
|
||||
key="auth-input-code"
|
||||
ref="code"
|
||||
v-model="code"
|
||||
:disabled="loading"
|
||||
@@ -45,6 +46,7 @@
|
||||
<v-flex xs12 class="pa-2">
|
||||
<v-text-field
|
||||
id="auth-username"
|
||||
key="auth-input-username"
|
||||
v-model="username"
|
||||
:disabled="loading || enterCode"
|
||||
name="username"
|
||||
@@ -70,6 +72,7 @@
|
||||
<v-flex xs12 class="px-2 py-1">
|
||||
<v-text-field
|
||||
id="auth-password"
|
||||
key="auth-input-password"
|
||||
v-model="password"
|
||||
:disabled="loading"
|
||||
name="password"
|
||||
|
||||
@@ -114,7 +114,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Find existing user record and update it, if necessary.
|
||||
if oidcUser := entity.OidcUser(userInfo, oidc.Username(userInfo, conf.OIDCUsername())); authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
|
||||
if oidcUser := entity.OidcUser(userInfo, provider.Issuer(), oidc.Username(userInfo, conf.OIDCUsername())); authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
|
||||
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthProviderIsNotOIDC.Error()})
|
||||
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrAuthProviderIsNotOIDC.Error())
|
||||
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
|
||||
@@ -311,13 +311,13 @@ func OIDCRedirect(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
// Update Subject ID (auth_id).
|
||||
user.SetAuthID(userInfo.Subject)
|
||||
user.SetAuthID(userInfo.Subject, provider.Issuer())
|
||||
|
||||
// Step 2: Create user session.
|
||||
sess := get.Session().New(c)
|
||||
sess.SetProvider(authn.ProviderOIDC)
|
||||
sess.SetMethod(authn.MethodDefault)
|
||||
sess.SetAuthID(user.AuthID)
|
||||
sess.SetAuthID(user.AuthID, provider.Issuer())
|
||||
sess.SetUser(user)
|
||||
sess.SetGrantType(authn.GrantAuthorizationCode)
|
||||
sess.IdToken = tokens.IDToken
|
||||
|
||||
@@ -23,9 +23,9 @@ func TestAuthListCommand(t *testing.T) {
|
||||
// Check command output for plausibility.
|
||||
// t.Logf(output)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, output, "alice")
|
||||
assert.Contains(t, output, "bob")
|
||||
assert.Contains(t, output, "visitor")
|
||||
assert.Contains(t, output, "alice ")
|
||||
assert.Contains(t, output, "bob ")
|
||||
assert.Contains(t, output, "visitor ")
|
||||
})
|
||||
t.Run("Alice", func(t *testing.T) {
|
||||
var err error
|
||||
@@ -42,9 +42,9 @@ func TestAuthListCommand(t *testing.T) {
|
||||
// t.Logf(output)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, output, "Session ID")
|
||||
assert.Contains(t, output, "alice")
|
||||
assert.NotContains(t, output, "bob")
|
||||
assert.NotContains(t, output, "visitor")
|
||||
assert.Contains(t, output, "alice ")
|
||||
assert.NotContains(t, output, "bob ")
|
||||
assert.NotContains(t, output, "visitor ")
|
||||
assert.NotContains(t, output, "| Preview Token |")
|
||||
})
|
||||
t.Run("CSV", func(t *testing.T) {
|
||||
@@ -62,8 +62,8 @@ func TestAuthListCommand(t *testing.T) {
|
||||
//t.Logf(output)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, output, "Session ID;")
|
||||
assert.Contains(t, output, "alice")
|
||||
assert.NotContains(t, output, "bob")
|
||||
assert.Contains(t, output, "alice;")
|
||||
assert.NotContains(t, output, "bob;")
|
||||
assert.NotContains(t, output, "visitor")
|
||||
})
|
||||
t.Run("Tokens", func(t *testing.T) {
|
||||
@@ -82,8 +82,8 @@ func TestAuthListCommand(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, output, "| Session ID |")
|
||||
assert.Contains(t, output, "| Preview Token |")
|
||||
assert.Contains(t, output, "alice")
|
||||
assert.NotContains(t, output, "bob")
|
||||
assert.Contains(t, output, "alice ")
|
||||
assert.NotContains(t, output, "bob ")
|
||||
assert.NotContains(t, output, "visitor")
|
||||
})
|
||||
t.Run("NoResult", func(t *testing.T) {
|
||||
|
||||
@@ -4,14 +4,17 @@ import (
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
)
|
||||
|
||||
// Usage hints for the user management subcommands.
|
||||
const (
|
||||
UserNameUsage = "full `NAME` for display in the interface"
|
||||
UserEmailUsage = "unique `EMAIL` address of the user"
|
||||
UserPasswordUsage = "`PASSWORD` for local authentication"
|
||||
UserRoleUsage = "user role `NAME` (leave blank for default)"
|
||||
UserPasswordUsage = "`PASSWORD` for local authentication (8-72 characters)"
|
||||
UserRoleUsage = "user account `ROLE` (admin or guest)"
|
||||
UserAuthUsage = "authentication `PROVIDER` (default, local, oidc or none)"
|
||||
UserAuthIDUsage = "authentication `ID` e.g. Subject ID or Distinguished Name (DN)"
|
||||
UserAdminUsage = "make user super admin with full access"
|
||||
UserNoLoginUsage = "disable login on the web interface"
|
||||
UserWebDAVUsage = "allow to sync files via WebDAV"
|
||||
@@ -53,6 +56,16 @@ var UserFlags = []cli.Flag{
|
||||
Usage: UserRoleUsage,
|
||||
Value: acl.RoleAdmin.String(),
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "auth, A",
|
||||
Usage: UserAuthUsage,
|
||||
Value: authn.ProviderDefault.String(),
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "auth-id",
|
||||
Usage: UserAuthIDUsage,
|
||||
Value: "",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "superadmin, s",
|
||||
Usage: UserAdminUsage,
|
||||
|
||||
@@ -77,7 +77,8 @@ func usersAddAction(ctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if interactive && frm.UserEmail == "" {
|
||||
// Enter account email.
|
||||
if interactive && frm.UserEmail == "" && frm.Provider().SupportsPasswordAuthentication() {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Email",
|
||||
}
|
||||
@@ -91,7 +92,9 @@ func usersAddAction(ctx *cli.Context) error {
|
||||
frm.UserEmail = clean.Email(res)
|
||||
}
|
||||
|
||||
if interactive && len([]rune(ctx.String("password"))) < entity.PasswordLength {
|
||||
// Enter account password.
|
||||
if interactive && (frm.Provider().RequiresLocalPassword() || frm.Password != "") &&
|
||||
len([]rune(ctx.String("password"))) < entity.PasswordLength {
|
||||
validate := func(input string) error {
|
||||
if len([]rune(input)) < entity.PasswordLength {
|
||||
return fmt.Errorf("password must have at least %d characters", entity.PasswordLength)
|
||||
|
||||
@@ -47,7 +47,7 @@ type Session struct {
|
||||
client *Client `gorm:"-" yaml:"-"`
|
||||
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
|
||||
AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"AuthDomain" yaml:"AuthDomain,omitempty"`
|
||||
AuthIssuer string `gorm:"type:VARBINARY(255);default:'';" json:"AuthIssuer,omitempty" yaml:"AuthIssuer,omitempty"`
|
||||
AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
|
||||
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
|
||||
GrantType string `gorm:"type:VARBINARY(64);default:'';" json:"GrantType" yaml:"GrantType,omitempty"`
|
||||
@@ -432,12 +432,13 @@ func (m *Session) AuthInfo() string {
|
||||
}
|
||||
|
||||
// SetAuthID sets a custom authentication identifier.
|
||||
func (m *Session) SetAuthID(id string) *Session {
|
||||
func (m *Session) SetAuthID(id, issuer string) *Session {
|
||||
if id == "" {
|
||||
return m
|
||||
}
|
||||
|
||||
m.AuthID = clean.Auth(id)
|
||||
m.AuthIssuer = clean.Uri(issuer)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func AuthLocal(user *User, f form.Login, s *Session, c *gin.Context) (provider a
|
||||
s.SetMethod(method)
|
||||
|
||||
// Set auth ID to the ID of the parent session.
|
||||
s.SetAuthID(authSess.ID)
|
||||
s.SetAuthID(authSess.ID, user.UserUID)
|
||||
|
||||
// Limit lifetime of the session to that of the parent session, if any.
|
||||
if authSess.SessExpires > 0 && s.SessExpires > authSess.SessExpires {
|
||||
|
||||
@@ -491,9 +491,10 @@ func TestSession_SetAuthID(t *testing.T) {
|
||||
AuthID: "test-session-auth-id",
|
||||
}
|
||||
|
||||
m := s.SetAuthID("")
|
||||
m := s.SetAuthID("", "https://accounts.google.com")
|
||||
|
||||
assert.Equal(t, "test-session-auth-id", m.AuthID)
|
||||
assert.Equal(t, "", m.AuthIssuer)
|
||||
})
|
||||
t.Run("New", func(t *testing.T) {
|
||||
s := &Session{
|
||||
@@ -502,9 +503,10 @@ func TestSession_SetAuthID(t *testing.T) {
|
||||
AuthID: "new-id",
|
||||
}
|
||||
|
||||
m := s.SetAuthID("new-id")
|
||||
m := s.SetAuthID("new-id", "https://accounts.google.com")
|
||||
|
||||
assert.Equal(t, "new-id", m.AuthID)
|
||||
assert.Equal(t, "https://accounts.google.com", m.AuthIssuer)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ type User struct {
|
||||
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;unique_index;" json:"UID" yaml:"UID"`
|
||||
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
|
||||
AuthIssuer string `gorm:"type:VARBINARY(255);default:'';" json:"AuthIssuer,omitempty" yaml:"AuthIssuer,omitempty"`
|
||||
AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
|
||||
UserName string `gorm:"size:200;index;" json:"Name" yaml:"Name,omitempty"`
|
||||
DisplayName string `gorm:"size:200;" json:"DisplayName" yaml:"DisplayName,omitempty"`
|
||||
@@ -105,7 +106,7 @@ func NewUser() (m *User) {
|
||||
}
|
||||
|
||||
// OidcUser creates a new OIDC user entity.
|
||||
func OidcUser(userInfo *oidc.UserInfo, userName string) User {
|
||||
func OidcUser(userInfo *oidc.UserInfo, issuer, userName string) User {
|
||||
authId := clean.Auth(userInfo.Subject)
|
||||
|
||||
if authId == "" {
|
||||
@@ -117,6 +118,7 @@ func OidcUser(userInfo *oidc.UserInfo, userName string) User {
|
||||
DisplayName: userInfo.Name,
|
||||
UserEmail: clean.Email(userInfo.Email),
|
||||
AuthProvider: authn.ProviderOIDC.String(),
|
||||
AuthIssuer: issuer,
|
||||
AuthID: authId,
|
||||
}
|
||||
}
|
||||
@@ -143,7 +145,11 @@ func FindUser(find User) *User {
|
||||
} else if rnd.IsUID(find.UserUID, UserUID) {
|
||||
stmt = stmt.Where("user_uid = ?", find.UserUID)
|
||||
} else if authn.ProviderOIDC.Equal(find.AuthProvider) && find.AuthID != "" {
|
||||
stmt = stmt.Where("auth_provider = ? AND auth_id = ?", find.AuthProvider, find.AuthID)
|
||||
if find.AuthIssuer == "" {
|
||||
stmt = stmt.Where("auth_provider = ? AND auth_id = ?", find.AuthProvider, find.AuthID)
|
||||
} else {
|
||||
stmt = stmt.Where("auth_provider = ? AND (auth_issuer = '' OR auth_issuer = ?) AND auth_id = ?", find.AuthProvider, find.AuthIssuer, find.AuthID)
|
||||
}
|
||||
} else if find.AuthProvider != "" && find.AuthID != "" && find.UserName != "" {
|
||||
stmt = stmt.Where("auth_provider = ? AND auth_id = ? OR user_name = ?", find.AuthProvider, find.AuthID, find.UserName)
|
||||
} else if find.UserName != "" {
|
||||
@@ -596,7 +602,7 @@ func (m *User) SetMethod(method authn.MethodType) *User {
|
||||
}
|
||||
|
||||
// SetAuthID sets a custom authentication identifier.
|
||||
func (m *User) SetAuthID(id string) *User {
|
||||
func (m *User) SetAuthID(id, issuer string) *User {
|
||||
if m == nil {
|
||||
return &User{}
|
||||
}
|
||||
@@ -606,6 +612,7 @@ func (m *User) SetAuthID(id string) *User {
|
||||
return m
|
||||
} else {
|
||||
m.AuthID = authId
|
||||
m.AuthIssuer = clean.Uri(issuer)
|
||||
}
|
||||
|
||||
// Make sure other users do not use the same identifier.
|
||||
@@ -1081,6 +1088,8 @@ func (m *User) Validate() (err error) {
|
||||
func (m *User) SetFormValues(frm form.User) *User {
|
||||
m.UserName = frm.Username()
|
||||
m.SetProvider(frm.Provider())
|
||||
m.SetMethod(frm.Method())
|
||||
m.SetAuthID(frm.AuthID, frm.AuthIssuer)
|
||||
m.UserEmail = frm.Email()
|
||||
m.DisplayName = frm.DisplayName
|
||||
m.SuperAdmin = frm.SuperAdmin
|
||||
@@ -1224,6 +1233,7 @@ func (m *User) PrivilegeLevelChange(f form.User) bool {
|
||||
m.UserAttr != f.Attr() ||
|
||||
m.AuthProvider != f.AuthProvider ||
|
||||
m.AuthMethod != f.AuthMethod ||
|
||||
m.AuthIssuer != f.AuthIssuer ||
|
||||
m.AuthID != f.AuthID ||
|
||||
m.BasePath != f.BasePath ||
|
||||
m.UploadPath != f.UploadPath
|
||||
@@ -1290,7 +1300,7 @@ func (m *User) SaveForm(f form.User, u *User) error {
|
||||
if u.IsSuperAdmin() {
|
||||
m.SetProvider(f.Provider())
|
||||
m.SetMethod(f.Method())
|
||||
m.SetAuthID(f.AuthID)
|
||||
m.SetAuthID(f.AuthID, f.AuthIssuer)
|
||||
}
|
||||
|
||||
m.SetBasePath(f.BasePath)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
@@ -14,12 +15,16 @@ import (
|
||||
func AddUser(frm form.User) error {
|
||||
user := NewUser().SetFormValues(frm)
|
||||
|
||||
if len([]rune(frm.Password)) < PasswordLength {
|
||||
return fmt.Errorf("password must have at least %d characters", PasswordLength)
|
||||
// Check auth id and password.
|
||||
if authId := clean.Auth(frm.AuthID); frm.Provider().Is(authn.ProviderOIDC) && len(authId) < 4 {
|
||||
return authn.ErrAuthIDRequired
|
||||
} else if len(frm.Password) > txt.ClipPassword {
|
||||
return fmt.Errorf("password must have less than %d characters", txt.ClipPassword)
|
||||
} else if (frm.Provider().RequiresLocalPassword() || frm.Password != "") && len([]rune(frm.Password)) < PasswordLength {
|
||||
return fmt.Errorf("password must have at least %d characters", PasswordLength)
|
||||
}
|
||||
|
||||
// Check username, role and email.
|
||||
if err := user.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -29,10 +34,12 @@ func AddUser(frm form.User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
pw := NewPassword(user.UserUID, frm.Password, false)
|
||||
if frm.Password != "" {
|
||||
pw := NewPassword(user.UserUID, frm.Password, false)
|
||||
|
||||
if err := tx.Create(&pw).Error; err != nil {
|
||||
return err
|
||||
if err := tx.Create(&pw).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("successfully added user %s", clean.LogQuote(user.Username()))
|
||||
|
||||
@@ -31,6 +31,23 @@ func (m *User) SetValuesFromCli(ctx *cli.Context) error {
|
||||
privilegeLevelChange = true
|
||||
}
|
||||
|
||||
// Authentication Provider.
|
||||
if ctx.IsSet("auth") {
|
||||
m.SetProvider(frm.Provider())
|
||||
privilegeLevelChange = true
|
||||
}
|
||||
|
||||
// Authentication ID.
|
||||
if ctx.IsSet("auth-id") {
|
||||
if frm.AuthID == "" {
|
||||
m.AuthID = ""
|
||||
m.AuthIssuer = ""
|
||||
} else {
|
||||
m.SetAuthID(frm.AuthID, m.AuthIssuer)
|
||||
}
|
||||
privilegeLevelChange = true
|
||||
}
|
||||
|
||||
// Super-admin status.
|
||||
if ctx.IsSet("superadmin") {
|
||||
m.SuperAdmin = frm.SuperAdmin
|
||||
|
||||
@@ -31,9 +31,10 @@ func TestOidcUser(t *testing.T) {
|
||||
info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d"
|
||||
info.PreferredUsername = "Jane Doe"
|
||||
|
||||
m := OidcUser(info, "jane.doe")
|
||||
m := OidcUser(info, "", "jane.doe")
|
||||
|
||||
assert.Equal(t, "oidc", m.AuthProvider)
|
||||
assert.Equal(t, "", m.AuthIssuer)
|
||||
assert.Equal(t, "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d", m.AuthID)
|
||||
assert.Equal(t, "jane@doe.com", m.UserEmail)
|
||||
assert.Equal(t, "jane.doe", m.UserName)
|
||||
@@ -49,9 +50,10 @@ func TestOidcUser(t *testing.T) {
|
||||
info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d"
|
||||
info.PreferredUsername = "Jane Doe"
|
||||
|
||||
m := OidcUser(info, "")
|
||||
m := OidcUser(info, "https://accounts.google.com", "")
|
||||
|
||||
assert.Equal(t, "oidc", m.AuthProvider)
|
||||
assert.Equal(t, "https://accounts.google.com", m.AuthIssuer)
|
||||
assert.Equal(t, "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d", m.AuthID)
|
||||
assert.Equal(t, "jane@doe.com", m.UserEmail)
|
||||
assert.Equal(t, "", m.UserName)
|
||||
@@ -67,9 +69,10 @@ func TestOidcUser(t *testing.T) {
|
||||
info.EmailVerified = true
|
||||
info.Subject = ""
|
||||
|
||||
m := OidcUser(info, "jane.doe")
|
||||
m := OidcUser(info, "https://accounts.google.com", "jane.doe")
|
||||
|
||||
assert.Equal(t, "", m.AuthProvider)
|
||||
assert.Equal(t, "", m.AuthIssuer)
|
||||
assert.Equal(t, "", m.AuthID)
|
||||
assert.Equal(t, "", m.UserEmail)
|
||||
assert.Equal(t, "", m.UserName)
|
||||
|
||||
@@ -177,4 +177,10 @@ var DialectMySQL = Migrations{
|
||||
Stage: "main",
|
||||
Statements: []string{"ALTER TABLE auth_sessions MODIFY IF EXISTS refresh_token VARBINARY(2048);", "ALTER TABLE auth_sessions MODIFY IF EXISTS id_token VARBINARY(2048);"},
|
||||
},
|
||||
{
|
||||
ID: "20240709-000001",
|
||||
Dialect: "mysql",
|
||||
Stage: "pre",
|
||||
Statements: []string{"ALTER IGNORE TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer;"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -99,4 +99,10 @@ var DialectSQLite3 = Migrations{
|
||||
Stage: "main",
|
||||
Statements: []string{"DELETE FROM auth_sessions;"},
|
||||
},
|
||||
{
|
||||
ID: "20240709-000001",
|
||||
Dialect: "sqlite3",
|
||||
Stage: "pre",
|
||||
Statements: []string{"ALTER TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer;"},
|
||||
},
|
||||
}
|
||||
|
||||
1
internal/entity/migrate/mysql/20240709-000001.pre.sql
Normal file
1
internal/entity/migrate/mysql/20240709-000001.pre.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER IGNORE TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer;
|
||||
1
internal/entity/migrate/sqlite3/20240709-000001.pre.sql
Normal file
1
internal/entity/migrate/sqlite3/20240709-000001.pre.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer;
|
||||
@@ -12,7 +12,8 @@ type User struct {
|
||||
UserName string `json:"Name,omitempty" yaml:"Name,omitempty"`
|
||||
AuthProvider string `json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
|
||||
AuthMethod string `json:"AuthMethod,omitempty" yaml:"AuthMethod,omitempty"`
|
||||
AuthID string `json:"AuthID,omitempty" yaml:"AuthMethod,omitempty"`
|
||||
AuthIssuer string `json:"AuthIssuer,omitempty" yaml:"AuthIssuer,omitempty"`
|
||||
AuthID string `json:"AuthID,omitempty" yaml:"AuthID,omitempty"`
|
||||
UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
|
||||
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
|
||||
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
|
||||
@@ -31,6 +32,7 @@ func NewUserFromCli(ctx *cli.Context) User {
|
||||
return User{
|
||||
UserName: clean.Username(ctx.Args().First()),
|
||||
AuthProvider: clean.TypeLower(ctx.String("auth")),
|
||||
AuthID: clean.Auth(ctx.String("auth-id")),
|
||||
UserEmail: clean.Email(ctx.String("email")),
|
||||
DisplayName: clean.Name(ctx.String("name")),
|
||||
UserRole: clean.Role(ctx.String("role")),
|
||||
|
||||
@@ -36,6 +36,7 @@ var (
|
||||
ErrInvalidClientID = errors.New("invalid client id")
|
||||
ErrInvalidAuthID = errors.New("invalid auth id")
|
||||
ErrAuthProviderIsNotOIDC = errors.New("auth provider is not oidc")
|
||||
ErrAuthIDRequired = errors.New("auth id required")
|
||||
ErrAuthCodeRequired = errors.New("auth code required")
|
||||
ErrClientIDRequired = errors.New("client id required")
|
||||
ErrInvalidClientSecret = errors.New("invalid client secret")
|
||||
|
||||
15
pkg/authn/issuer.go
Normal file
15
pkg/authn/issuer.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package authn
|
||||
|
||||
import "github.com/photoprism/photoprism/pkg/clean"
|
||||
|
||||
// IssuerUri represents an authentication issuer URI.
|
||||
type IssuerUri = string
|
||||
|
||||
const (
|
||||
IssuerDefault IssuerUri = ""
|
||||
)
|
||||
|
||||
// Issuer returns a sanitized issuer URI.
|
||||
func Issuer(uri string) IssuerUri {
|
||||
return clean.Uri(uri)
|
||||
}
|
||||
16
pkg/authn/issuer_test.go
Normal file
16
pkg/authn/issuer_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIssuer(t *testing.T) {
|
||||
assert.Equal(t, "", Issuer(""))
|
||||
assert.Equal(t, "", Issuer(" "))
|
||||
assert.Equal(t, "https://accounts.google.com", Issuer("https://accounts.google.com"))
|
||||
assert.Equal(t, "user://123456", Issuer("user://123456"))
|
||||
assert.Equal(t, "issuer.example.com", Issuer("issuer.example.com"))
|
||||
assert.Equal(t, "example", Issuer(" example "))
|
||||
}
|
||||
@@ -31,11 +31,11 @@ var LocalProviders = list.List{
|
||||
string(ProviderOIDC),
|
||||
}
|
||||
|
||||
// ClientProviders contains all client authentication providers.
|
||||
var ClientProviders = list.List{
|
||||
string(ProviderClient),
|
||||
string(ProviderApplication),
|
||||
string(ProviderAccessToken),
|
||||
// LocalPasswordRequiredProviders contains authentication providers which require a local password.
|
||||
var LocalPasswordRequiredProviders = list.List{
|
||||
string(ProviderUndefined),
|
||||
string(ProviderDefault),
|
||||
string(ProviderLocal),
|
||||
}
|
||||
|
||||
// PasswordProviders contains authentication providers which support password authentication (local and remote).
|
||||
@@ -52,6 +52,13 @@ var PasscodeProviders = list.List{
|
||||
string(ProviderLDAP),
|
||||
}
|
||||
|
||||
// ClientProviders contains all client authentication providers.
|
||||
var ClientProviders = list.List{
|
||||
string(ProviderClient),
|
||||
string(ProviderApplication),
|
||||
string(ProviderAccessToken),
|
||||
}
|
||||
|
||||
// Provider casts a string to a normalized provider type.
|
||||
func Provider(s string) ProviderType {
|
||||
s = clean.TypeLowerUnderscore(s)
|
||||
@@ -144,6 +151,11 @@ func (t ProviderType) IsUndefined() bool {
|
||||
return t == ""
|
||||
}
|
||||
|
||||
// IsOIDC checks if the provider is OpenID Connect (OIDC).
|
||||
func (t ProviderType) IsOIDC() bool {
|
||||
return t == ProviderOIDC
|
||||
}
|
||||
|
||||
// IsLocal checks if local authentication is possible.
|
||||
func (t ProviderType) IsLocal() bool {
|
||||
return list.Contains(LocalProviders, string(t))
|
||||
@@ -164,6 +176,11 @@ func (t ProviderType) IsDefault() bool {
|
||||
return t.String() == ProviderDefault.String()
|
||||
}
|
||||
|
||||
// RequiresLocalPassword checks if the provider allows a password to be checked for authentication.
|
||||
func (t ProviderType) RequiresLocalPassword() bool {
|
||||
return list.Contains(LocalPasswordRequiredProviders, string(t))
|
||||
}
|
||||
|
||||
// SupportsPasswordAuthentication checks if the provider allows a password to be checked for authentication.
|
||||
func (t ProviderType) SupportsPasswordAuthentication() bool {
|
||||
return list.Contains(PasswordProviders, string(t))
|
||||
|
||||
Reference in New Issue
Block a user