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:
Michael Mayer
2024-07-09 11:01:59 +02:00
parent e87f32fa5c
commit fe9caaa83b
26 changed files with 200 additions and 68 deletions

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"

View File

@@ -49,9 +49,12 @@ services:
## OpenID Connect (pre-configured for local tests): ## OpenID Connect (pre-configured for local tests):
## see https://keycloak.localssl.dev/realms/master/.well-known/openid-configuration ## see https://keycloak.localssl.dev/realms/master/.well-known/openid-configuration
PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/realms/master" PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/realms/master"
PHOTOPRISM_OIDC_INSECURE: "true"
PHOTOPRISM_OIDC_CLIENT: "photoprism-develop" PHOTOPRISM_OIDC_CLIENT: "photoprism-develop"
PHOTOPRISM_OIDC_SECRET: "9d8351a0-ca01-4556-9c37-85eb634869b9" 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): ## LDAP Authentication (pre-configured for local tests):
PHOTOPRISM_LDAP_URI: "ldap://dummy-ldap:389" PHOTOPRISM_LDAP_URI: "ldap://dummy-ldap:389"
PHOTOPRISM_LDAP_INSECURE: "true" PHOTOPRISM_LDAP_INSECURE: "true"

View File

@@ -6467,9 +6467,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.818", "version": "1.4.819",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.818.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.819.tgz",
"integrity": "sha512-eGvIk2V0dGImV9gWLq8fDfTTsCAeMDwZqEPMr+jMInxZdnp9Us8UpovYpRCf9NQ7VOFgrN2doNSgvISbsbNpxA==", "integrity": "sha512-8RwI6gKUokbHWcN3iRij/qpvf/wCbIVY5slODi85werwqUQwpFXM+dvUBND93Qh7SB0pW3Hlq3/wZsqQ3M9Jaw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
@@ -7582,9 +7582,9 @@
} }
}, },
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
"integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"estraverse": "^5.1.0" "estraverse": "^5.1.0"
@@ -8099,9 +8099,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/flow-remove-types": { "node_modules/flow-remove-types": {
"version": "2.239.0", "version": "2.239.1",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.239.0.tgz", "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.239.1.tgz",
"integrity": "sha512-IBFfuqEe46Iegl4FwSq21cy3MndTTzc6yWboK+CcDyP2wyoMJIy2wbT+FJbissh0z9rRDavv5nNVUqKkty9KOQ==", "integrity": "sha512-Kam48LEA8DGmyG06fsbUA7sXhRppU6Jc903AvCChPfPmiTDAXW7xqWpYsbKFKuypuvc9BJI3tznFK8MI1Ho6oA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"hermes-parser": "0.22.0", "hermes-parser": "0.22.0",
@@ -9672,15 +9672,15 @@
} }
}, },
"node_modules/jackspeak": { "node_modules/jackspeak": {
"version": "3.4.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.1.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.2.tgz",
"integrity": "sha512-U23pQPDnmYybVkYjObcuYMk43VRlMLLqLI+RdZy8s8WV8WsxO9SnqSroKaluuvcNOdCAlauKszDwd+umbot5Mg==", "integrity": "sha512-qH3nOSj8q/8+Eg8LUPOq3C+6HWkpUioIjDsq1+D4zY91oZvpPttw8GwtF1nReRYKXl+1AORyFqtm2f5Q1SB6/Q==",
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/cliui": "^8.0.2" "@isaacs/cliui": "^8.0.2"
}, },
"engines": { "engines": {
"node": ">=18" "node": "14 >=14.21 || 16 >=16.20 || >=18"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
@@ -10676,9 +10676,9 @@
} }
}, },
"node_modules/minizlib/node_modules/glob": { "node_modules/minizlib/node_modules/glob": {
"version": "10.4.3", "version": "10.4.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.4.tgz",
"integrity": "sha512-Q38SGlYRpVtDBPSWEylRyctn7uDeTp4NQERTLiCT1FqA9JXPYWqAVmQU6qh4r/zMM5ehxTcbaO8EjhWnvEhmyg==", "integrity": "sha512-XsOKvHsu38Xe19ZQupE6N/HENeHQBA05o3hV8labZZT2zYDg1+emxWHnc/Bm9AcCMPXfD6jt+QC7zC5JSFyumw==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"foreground-child": "^3.1.0", "foreground-child": "^3.1.0",
@@ -10692,7 +10692,7 @@
"glob": "dist/esm/bin.mjs" "glob": "dist/esm/bin.mjs"
}, },
"engines": { "engines": {
"node": ">=18" "node": "14 >=14.21 || 16 >=16.20 || 18 || 20 || >=22"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
@@ -10714,9 +10714,9 @@
} }
}, },
"node_modules/minizlib/node_modules/rimraf": { "node_modules/minizlib/node_modules/rimraf": {
"version": "5.0.8", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.8.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.9.tgz",
"integrity": "sha512-XSh0V2/yNhDEi8HwdIefD8MLgs4LQXPag/nEJWs3YUc3Upn+UHa1GyIkEg9xSSNt7HnkO5FjTvmcRzgf+8UZuw==", "integrity": "sha512-3i7b8OcswU6CpU8Ej89quJD4O98id7TtVM5U4Mybh84zQXdrFmDLouWBEEaD/QfO3gDDfH+AGFCGsR7kngzQnA==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"glob": "^10.3.7" "glob": "^10.3.7"
@@ -10725,7 +10725,7 @@
"rimraf": "dist/esm/bin.mjs" "rimraf": "dist/esm/bin.mjs"
}, },
"engines": { "engines": {
"node": ">=18" "node": "14 >=14.20 || 16 >=16.20 || >=18"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
@@ -11491,12 +11491,12 @@
} }
}, },
"node_modules/path-scurry/node_modules/lru-cache": { "node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.0", "version": "10.4.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.2.tgz",
"integrity": "sha512-bfJaPTuEiTYBu+ulDaeQ0F+uLmlfFkMgXj4cbwfuMSjgObGMzb55FMMbDvbRU0fAHZ4sLGkz2mKwcMg8Dvm8Ww==", "integrity": "sha512-voV4dDrdVZVNz84n39LFKDaRzfwhdzJ7akpyXfTMxCgRUp07U3lcJUXRlhTKP17rgt09sUzLi5iCitpEAr+6ug==",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=18" "node": "14 || 16 || 18 || 20 || >=22"
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {

View File

@@ -43,7 +43,7 @@ export class Session extends RestModel {
ClientName: "", ClientName: "",
AuthProvider: "", AuthProvider: "",
AuthMethod: "", AuthMethod: "",
AuthDomain: "", AuthIssuer: "",
AuthID: "", AuthID: "",
AuthScope: "", AuthScope: "",
GrantType: "", GrantType: "",

View File

@@ -40,6 +40,7 @@ export class User extends RestModel {
UUID: "", UUID: "",
AuthProvider: "", AuthProvider: "",
AuthMethod: "", AuthMethod: "",
AuthIssuer: "",
AuthID: "", AuthID: "",
Name: "", Name: "",
DisplayName: "", DisplayName: "",
@@ -212,6 +213,10 @@ export class User extends RestModel {
return this.AuthProvider && this.AuthProvider === "ldap"; return this.AuthProvider && this.AuthProvider === "ldap";
} }
requiresPassword() {
return !this.AuthProvider || this.AuthProvider === "default" || this.AuthProvider === "local";
}
authInfo() { authInfo() {
if (!this || !this.AuthProvider) { if (!this || !this.AuthProvider) {
return $gettext("Default"); return $gettext("Default");

View File

@@ -15,6 +15,7 @@
<v-flex xs12 class="px-2 py-1"> <v-flex xs12 class="px-2 py-1">
<v-text-field <v-text-field
id="one-time-code" id="one-time-code"
key="auth-input-code"
ref="code" ref="code"
v-model="code" v-model="code"
:disabled="loading" :disabled="loading"
@@ -45,6 +46,7 @@
<v-flex xs12 class="pa-2"> <v-flex xs12 class="pa-2">
<v-text-field <v-text-field
id="auth-username" id="auth-username"
key="auth-input-username"
v-model="username" v-model="username"
:disabled="loading || enterCode" :disabled="loading || enterCode"
name="username" name="username"
@@ -70,6 +72,7 @@
<v-flex xs12 class="px-2 py-1"> <v-flex xs12 class="px-2 py-1">
<v-text-field <v-text-field
id="auth-password" id="auth-password"
key="auth-input-password"
v-model="password" v-model="password"
:disabled="loading" :disabled="loading"
name="password" name="password"

View File

@@ -114,7 +114,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
} }
// Find existing user record and update it, if necessary. // 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.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthProviderIsNotOIDC.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, 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))) 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). // Update Subject ID (auth_id).
user.SetAuthID(userInfo.Subject) user.SetAuthID(userInfo.Subject, provider.Issuer())
// Step 2: Create user session. // Step 2: Create user session.
sess := get.Session().New(c) sess := get.Session().New(c)
sess.SetProvider(authn.ProviderOIDC) sess.SetProvider(authn.ProviderOIDC)
sess.SetMethod(authn.MethodDefault) sess.SetMethod(authn.MethodDefault)
sess.SetAuthID(user.AuthID) sess.SetAuthID(user.AuthID, provider.Issuer())
sess.SetUser(user) sess.SetUser(user)
sess.SetGrantType(authn.GrantAuthorizationCode) sess.SetGrantType(authn.GrantAuthorizationCode)
sess.IdToken = tokens.IDToken sess.IdToken = tokens.IDToken

View File

@@ -23,9 +23,9 @@ func TestAuthListCommand(t *testing.T) {
// Check command output for plausibility. // Check command output for plausibility.
// t.Logf(output) // t.Logf(output)
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, output, "alice") assert.Contains(t, output, "alice ")
assert.Contains(t, output, "bob") assert.Contains(t, output, "bob ")
assert.Contains(t, output, "visitor") assert.Contains(t, output, "visitor ")
}) })
t.Run("Alice", func(t *testing.T) { t.Run("Alice", func(t *testing.T) {
var err error var err error
@@ -42,9 +42,9 @@ func TestAuthListCommand(t *testing.T) {
// t.Logf(output) // t.Logf(output)
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, output, "Session ID") assert.Contains(t, output, "Session ID")
assert.Contains(t, output, "alice") assert.Contains(t, output, "alice ")
assert.NotContains(t, output, "bob") assert.NotContains(t, output, "bob ")
assert.NotContains(t, output, "visitor") assert.NotContains(t, output, "visitor ")
assert.NotContains(t, output, "| Preview Token |") assert.NotContains(t, output, "| Preview Token |")
}) })
t.Run("CSV", func(t *testing.T) { t.Run("CSV", func(t *testing.T) {
@@ -62,8 +62,8 @@ func TestAuthListCommand(t *testing.T) {
//t.Logf(output) //t.Logf(output)
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, output, "Session ID;") assert.Contains(t, output, "Session ID;")
assert.Contains(t, output, "alice") assert.Contains(t, output, "alice;")
assert.NotContains(t, output, "bob") assert.NotContains(t, output, "bob;")
assert.NotContains(t, output, "visitor") assert.NotContains(t, output, "visitor")
}) })
t.Run("Tokens", func(t *testing.T) { t.Run("Tokens", func(t *testing.T) {
@@ -82,8 +82,8 @@ func TestAuthListCommand(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, output, "| Session ID |") assert.Contains(t, output, "| Session ID |")
assert.Contains(t, output, "| Preview Token |") assert.Contains(t, output, "| Preview Token |")
assert.Contains(t, output, "alice") assert.Contains(t, output, "alice ")
assert.NotContains(t, output, "bob") assert.NotContains(t, output, "bob ")
assert.NotContains(t, output, "visitor") assert.NotContains(t, output, "visitor")
}) })
t.Run("NoResult", func(t *testing.T) { t.Run("NoResult", func(t *testing.T) {

View File

@@ -4,14 +4,17 @@ import (
"github.com/urfave/cli" "github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/pkg/authn"
) )
// Usage hints for the user management subcommands. // Usage hints for the user management subcommands.
const ( const (
UserNameUsage = "full `NAME` for display in the interface" UserNameUsage = "full `NAME` for display in the interface"
UserEmailUsage = "unique `EMAIL` address of the user" UserEmailUsage = "unique `EMAIL` address of the user"
UserPasswordUsage = "`PASSWORD` for local authentication" UserPasswordUsage = "`PASSWORD` for local authentication (8-72 characters)"
UserRoleUsage = "user role `NAME` (leave blank for default)" 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" UserAdminUsage = "make user super admin with full access"
UserNoLoginUsage = "disable login on the web interface" UserNoLoginUsage = "disable login on the web interface"
UserWebDAVUsage = "allow to sync files via WebDAV" UserWebDAVUsage = "allow to sync files via WebDAV"
@@ -53,6 +56,16 @@ var UserFlags = []cli.Flag{
Usage: UserRoleUsage, Usage: UserRoleUsage,
Value: acl.RoleAdmin.String(), 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{ cli.BoolFlag{
Name: "superadmin, s", Name: "superadmin, s",
Usage: UserAdminUsage, Usage: UserAdminUsage,

View File

@@ -77,7 +77,8 @@ func usersAddAction(ctx *cli.Context) error {
return nil return nil
} }
if interactive && frm.UserEmail == "" { // Enter account email.
if interactive && frm.UserEmail == "" && frm.Provider().SupportsPasswordAuthentication() {
prompt := promptui.Prompt{ prompt := promptui.Prompt{
Label: "Email", Label: "Email",
} }
@@ -91,7 +92,9 @@ func usersAddAction(ctx *cli.Context) error {
frm.UserEmail = clean.Email(res) 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 { validate := func(input string) 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)

View File

@@ -47,7 +47,7 @@ type Session struct {
client *Client `gorm:"-" yaml:"-"` client *Client `gorm:"-" yaml:"-"`
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"` AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,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"` AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"` AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
GrantType string `gorm:"type:VARBINARY(64);default:'';" json:"GrantType" yaml:"GrantType,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. // SetAuthID sets a custom authentication identifier.
func (m *Session) SetAuthID(id string) *Session { func (m *Session) SetAuthID(id, issuer string) *Session {
if id == "" { if id == "" {
return m return m
} }
m.AuthID = clean.Auth(id) m.AuthID = clean.Auth(id)
m.AuthIssuer = clean.Uri(issuer)
return m return m
} }

View File

@@ -148,7 +148,7 @@ func AuthLocal(user *User, f form.Login, s *Session, c *gin.Context) (provider a
s.SetMethod(method) s.SetMethod(method)
// Set auth ID to the ID of the parent session. // 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. // Limit lifetime of the session to that of the parent session, if any.
if authSess.SessExpires > 0 && s.SessExpires > authSess.SessExpires { if authSess.SessExpires > 0 && s.SessExpires > authSess.SessExpires {

View File

@@ -491,9 +491,10 @@ func TestSession_SetAuthID(t *testing.T) {
AuthID: "test-session-auth-id", 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, "test-session-auth-id", m.AuthID)
assert.Equal(t, "", m.AuthIssuer)
}) })
t.Run("New", func(t *testing.T) { t.Run("New", func(t *testing.T) {
s := &Session{ s := &Session{
@@ -502,9 +503,10 @@ func TestSession_SetAuthID(t *testing.T) {
AuthID: "new-id", 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, "new-id", m.AuthID)
assert.Equal(t, "https://accounts.google.com", m.AuthIssuer)
}) })
} }

View File

@@ -50,6 +50,7 @@ type User struct {
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;unique_index;" json:"UID" yaml:"UID"` 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"` AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,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"` AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
UserName string `gorm:"size:200;index;" json:"Name" yaml:"Name,omitempty"` UserName string `gorm:"size:200;index;" json:"Name" yaml:"Name,omitempty"`
DisplayName string `gorm:"size:200;" json:"DisplayName" yaml:"DisplayName,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. // 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) authId := clean.Auth(userInfo.Subject)
if authId == "" { if authId == "" {
@@ -117,6 +118,7 @@ func OidcUser(userInfo *oidc.UserInfo, userName string) User {
DisplayName: userInfo.Name, DisplayName: userInfo.Name,
UserEmail: clean.Email(userInfo.Email), UserEmail: clean.Email(userInfo.Email),
AuthProvider: authn.ProviderOIDC.String(), AuthProvider: authn.ProviderOIDC.String(),
AuthIssuer: issuer,
AuthID: authId, AuthID: authId,
} }
} }
@@ -143,7 +145,11 @@ func FindUser(find User) *User {
} else if rnd.IsUID(find.UserUID, UserUID) { } else if rnd.IsUID(find.UserUID, UserUID) {
stmt = stmt.Where("user_uid = ?", find.UserUID) stmt = stmt.Where("user_uid = ?", find.UserUID)
} else if authn.ProviderOIDC.Equal(find.AuthProvider) && find.AuthID != "" { } 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 != "" { } 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) stmt = stmt.Where("auth_provider = ? AND auth_id = ? OR user_name = ?", find.AuthProvider, find.AuthID, find.UserName)
} else if find.UserName != "" { } else if find.UserName != "" {
@@ -596,7 +602,7 @@ func (m *User) SetMethod(method authn.MethodType) *User {
} }
// SetAuthID sets a custom authentication identifier. // SetAuthID sets a custom authentication identifier.
func (m *User) SetAuthID(id string) *User { func (m *User) SetAuthID(id, issuer string) *User {
if m == nil { if m == nil {
return &User{} return &User{}
} }
@@ -606,6 +612,7 @@ func (m *User) SetAuthID(id string) *User {
return m return m
} else { } else {
m.AuthID = authId m.AuthID = authId
m.AuthIssuer = clean.Uri(issuer)
} }
// Make sure other users do not use the same identifier. // 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 { func (m *User) SetFormValues(frm form.User) *User {
m.UserName = frm.Username() m.UserName = frm.Username()
m.SetProvider(frm.Provider()) m.SetProvider(frm.Provider())
m.SetMethod(frm.Method())
m.SetAuthID(frm.AuthID, frm.AuthIssuer)
m.UserEmail = frm.Email() m.UserEmail = frm.Email()
m.DisplayName = frm.DisplayName m.DisplayName = frm.DisplayName
m.SuperAdmin = frm.SuperAdmin m.SuperAdmin = frm.SuperAdmin
@@ -1224,6 +1233,7 @@ func (m *User) PrivilegeLevelChange(f form.User) bool {
m.UserAttr != f.Attr() || m.UserAttr != f.Attr() ||
m.AuthProvider != f.AuthProvider || m.AuthProvider != f.AuthProvider ||
m.AuthMethod != f.AuthMethod || m.AuthMethod != f.AuthMethod ||
m.AuthIssuer != f.AuthIssuer ||
m.AuthID != f.AuthID || m.AuthID != f.AuthID ||
m.BasePath != f.BasePath || m.BasePath != f.BasePath ||
m.UploadPath != f.UploadPath m.UploadPath != f.UploadPath
@@ -1290,7 +1300,7 @@ func (m *User) SaveForm(f form.User, u *User) error {
if u.IsSuperAdmin() { if u.IsSuperAdmin() {
m.SetProvider(f.Provider()) m.SetProvider(f.Provider())
m.SetMethod(f.Method()) m.SetMethod(f.Method())
m.SetAuthID(f.AuthID) m.SetAuthID(f.AuthID, f.AuthIssuer)
} }
m.SetBasePath(f.BasePath) m.SetBasePath(f.BasePath)

View File

@@ -6,6 +6,7 @@ import (
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"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"
) )
@@ -14,12 +15,16 @@ import (
func AddUser(frm form.User) error { func AddUser(frm form.User) error {
user := NewUser().SetFormValues(frm) user := NewUser().SetFormValues(frm)
if len([]rune(frm.Password)) < PasswordLength { // Check auth id and password.
return fmt.Errorf("password must have at least %d characters", PasswordLength) if authId := clean.Auth(frm.AuthID); frm.Provider().Is(authn.ProviderOIDC) && len(authId) < 4 {
return authn.ErrAuthIDRequired
} else if len(frm.Password) > txt.ClipPassword { } else if len(frm.Password) > txt.ClipPassword {
return fmt.Errorf("password must have less than %d characters", 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 { if err := user.Validate(); err != nil {
return err return err
} }
@@ -29,10 +34,12 @@ func AddUser(frm form.User) error {
return err 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 { if err := tx.Create(&pw).Error; err != nil {
return err return err
}
} }
log.Infof("successfully added user %s", clean.LogQuote(user.Username())) log.Infof("successfully added user %s", clean.LogQuote(user.Username()))

View File

@@ -31,6 +31,23 @@ func (m *User) SetValuesFromCli(ctx *cli.Context) error {
privilegeLevelChange = true 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. // Super-admin status.
if ctx.IsSet("superadmin") { if ctx.IsSet("superadmin") {
m.SuperAdmin = frm.SuperAdmin m.SuperAdmin = frm.SuperAdmin

View File

@@ -31,9 +31,10 @@ func TestOidcUser(t *testing.T) {
info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d" info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d"
info.PreferredUsername = "Jane Doe" info.PreferredUsername = "Jane Doe"
m := OidcUser(info, "jane.doe") m := OidcUser(info, "", "jane.doe")
assert.Equal(t, "oidc", m.AuthProvider) assert.Equal(t, "oidc", m.AuthProvider)
assert.Equal(t, "", m.AuthIssuer)
assert.Equal(t, "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d", m.AuthID) assert.Equal(t, "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d", m.AuthID)
assert.Equal(t, "jane@doe.com", m.UserEmail) assert.Equal(t, "jane@doe.com", m.UserEmail)
assert.Equal(t, "jane.doe", m.UserName) assert.Equal(t, "jane.doe", m.UserName)
@@ -49,9 +50,10 @@ func TestOidcUser(t *testing.T) {
info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d" info.Subject = "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d"
info.PreferredUsername = "Jane Doe" info.PreferredUsername = "Jane Doe"
m := OidcUser(info, "") m := OidcUser(info, "https://accounts.google.com", "")
assert.Equal(t, "oidc", m.AuthProvider) 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, "e3a9f4a6-9d60-47cb-9bf5-02bd15b0c68d", m.AuthID)
assert.Equal(t, "jane@doe.com", m.UserEmail) assert.Equal(t, "jane@doe.com", m.UserEmail)
assert.Equal(t, "", m.UserName) assert.Equal(t, "", m.UserName)
@@ -67,9 +69,10 @@ func TestOidcUser(t *testing.T) {
info.EmailVerified = true info.EmailVerified = true
info.Subject = "" 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.AuthProvider)
assert.Equal(t, "", m.AuthIssuer)
assert.Equal(t, "", m.AuthID) assert.Equal(t, "", m.AuthID)
assert.Equal(t, "", m.UserEmail) assert.Equal(t, "", m.UserEmail)
assert.Equal(t, "", m.UserName) assert.Equal(t, "", m.UserName)

View File

@@ -177,4 +177,10 @@ var DialectMySQL = Migrations{
Stage: "main", 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);"}, 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;"},
},
} }

View File

@@ -99,4 +99,10 @@ var DialectSQLite3 = Migrations{
Stage: "main", Stage: "main",
Statements: []string{"DELETE FROM auth_sessions;"}, 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;"},
},
} }

View File

@@ -0,0 +1 @@
ALTER IGNORE TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer;

View File

@@ -0,0 +1 @@
ALTER TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer;

View File

@@ -12,7 +12,8 @@ type User struct {
UserName string `json:"Name,omitempty" yaml:"Name,omitempty"` UserName string `json:"Name,omitempty" yaml:"Name,omitempty"`
AuthProvider string `json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"` AuthProvider string `json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
AuthMethod string `json:"AuthMethod,omitempty" yaml:"AuthMethod,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"` UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"` DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"` UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
@@ -31,6 +32,7 @@ func NewUserFromCli(ctx *cli.Context) User {
return User{ return User{
UserName: clean.Username(ctx.Args().First()), UserName: clean.Username(ctx.Args().First()),
AuthProvider: clean.TypeLower(ctx.String("auth")), AuthProvider: clean.TypeLower(ctx.String("auth")),
AuthID: clean.Auth(ctx.String("auth-id")),
UserEmail: clean.Email(ctx.String("email")), UserEmail: clean.Email(ctx.String("email")),
DisplayName: clean.Name(ctx.String("name")), DisplayName: clean.Name(ctx.String("name")),
UserRole: clean.Role(ctx.String("role")), UserRole: clean.Role(ctx.String("role")),

View File

@@ -36,6 +36,7 @@ var (
ErrInvalidClientID = errors.New("invalid client id") ErrInvalidClientID = errors.New("invalid client id")
ErrInvalidAuthID = errors.New("invalid auth id") ErrInvalidAuthID = errors.New("invalid auth id")
ErrAuthProviderIsNotOIDC = errors.New("auth provider is not oidc") ErrAuthProviderIsNotOIDC = errors.New("auth provider is not oidc")
ErrAuthIDRequired = errors.New("auth id required")
ErrAuthCodeRequired = errors.New("auth code required") ErrAuthCodeRequired = errors.New("auth code required")
ErrClientIDRequired = errors.New("client id required") ErrClientIDRequired = errors.New("client id required")
ErrInvalidClientSecret = errors.New("invalid client secret") ErrInvalidClientSecret = errors.New("invalid client secret")

15
pkg/authn/issuer.go Normal file
View 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
View 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 "))
}

View File

@@ -31,11 +31,11 @@ var LocalProviders = list.List{
string(ProviderOIDC), string(ProviderOIDC),
} }
// ClientProviders contains all client authentication providers. // LocalPasswordRequiredProviders contains authentication providers which require a local password.
var ClientProviders = list.List{ var LocalPasswordRequiredProviders = list.List{
string(ProviderClient), string(ProviderUndefined),
string(ProviderApplication), string(ProviderDefault),
string(ProviderAccessToken), string(ProviderLocal),
} }
// PasswordProviders contains authentication providers which support password authentication (local and remote). // PasswordProviders contains authentication providers which support password authentication (local and remote).
@@ -52,6 +52,13 @@ var PasscodeProviders = list.List{
string(ProviderLDAP), 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. // Provider casts a string to a normalized provider type.
func Provider(s string) ProviderType { func Provider(s string) ProviderType {
s = clean.TypeLowerUnderscore(s) s = clean.TypeLowerUnderscore(s)
@@ -144,6 +151,11 @@ func (t ProviderType) IsUndefined() bool {
return t == "" 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. // IsLocal checks if local authentication is possible.
func (t ProviderType) IsLocal() bool { func (t ProviderType) IsLocal() bool {
return list.Contains(LocalProviders, string(t)) return list.Contains(LocalProviders, string(t))
@@ -164,6 +176,11 @@ func (t ProviderType) IsDefault() bool {
return t.String() == ProviderDefault.String() 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. // SupportsPasswordAuthentication checks if the provider allows a password to be checked for authentication.
func (t ProviderType) SupportsPasswordAuthentication() bool { func (t ProviderType) SupportsPasswordAuthentication() bool {
return list.Contains(PasswordProviders, string(t)) return list.Contains(PasswordProviders, string(t))