diff --git a/assets/locales/messages.pot b/assets/locales/messages.pot index 5250a01ad..d8ba90798 100644 --- a/assets/locales/messages.pot +++ b/assets/locales/messages.pot @@ -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 \n" "Language-Team: LANGUAGE \n" diff --git a/compose.yaml b/compose.yaml index 1bdcc2858..411ad7ead 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 554437b67..73e5cfe15 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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": { diff --git a/frontend/src/model/session.js b/frontend/src/model/session.js index d158a7551..f2cfffd7e 100644 --- a/frontend/src/model/session.js +++ b/frontend/src/model/session.js @@ -43,7 +43,7 @@ export class Session extends RestModel { ClientName: "", AuthProvider: "", AuthMethod: "", - AuthDomain: "", + AuthIssuer: "", AuthID: "", AuthScope: "", GrantType: "", diff --git a/frontend/src/model/user.js b/frontend/src/model/user.js index a185f9662..ed0f86308 100644 --- a/frontend/src/model/user.js +++ b/frontend/src/model/user.js @@ -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"); diff --git a/frontend/src/page/auth/login.vue b/frontend/src/page/auth/login.vue index 369f13772..fe131b275 100644 --- a/frontend/src/page/auth/login.vue +++ b/frontend/src/page/auth/login.vue @@ -15,6 +15,7 @@ 0 && s.SessExpires > authSess.SessExpires { diff --git a/internal/entity/auth_session_test.go b/internal/entity/auth_session_test.go index 86419d32d..fe8d9eacd 100644 --- a/internal/entity/auth_session_test.go +++ b/internal/entity/auth_session_test.go @@ -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) }) } diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go index 9d376ee58..40178ddac 100644 --- a/internal/entity/auth_user.go +++ b/internal/entity/auth_user.go @@ -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) diff --git a/internal/entity/auth_user_add.go b/internal/entity/auth_user_add.go index db1e7e59d..ce4039013 100644 --- a/internal/entity/auth_user_add.go +++ b/internal/entity/auth_user_add.go @@ -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())) diff --git a/internal/entity/auth_user_cli.go b/internal/entity/auth_user_cli.go index ddc6f7843..851be6555 100644 --- a/internal/entity/auth_user_cli.go +++ b/internal/entity/auth_user_cli.go @@ -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 diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go index 8c5baa7b9..5b73d547a 100644 --- a/internal/entity/auth_user_test.go +++ b/internal/entity/auth_user_test.go @@ -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) diff --git a/internal/entity/migrate/dialect_mysql.go b/internal/entity/migrate/dialect_mysql.go index d1ba652aa..895281952 100644 --- a/internal/entity/migrate/dialect_mysql.go +++ b/internal/entity/migrate/dialect_mysql.go @@ -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;"}, + }, } diff --git a/internal/entity/migrate/dialect_sqlite3.go b/internal/entity/migrate/dialect_sqlite3.go index 0e2eb3e41..065b81a12 100644 --- a/internal/entity/migrate/dialect_sqlite3.go +++ b/internal/entity/migrate/dialect_sqlite3.go @@ -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;"}, + }, } diff --git a/internal/entity/migrate/mysql/20240709-000001.pre.sql b/internal/entity/migrate/mysql/20240709-000001.pre.sql new file mode 100644 index 000000000..3d17fb9b9 --- /dev/null +++ b/internal/entity/migrate/mysql/20240709-000001.pre.sql @@ -0,0 +1 @@ +ALTER IGNORE TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer; \ No newline at end of file diff --git a/internal/entity/migrate/sqlite3/20240709-000001.pre.sql b/internal/entity/migrate/sqlite3/20240709-000001.pre.sql new file mode 100644 index 000000000..0902cdb6b --- /dev/null +++ b/internal/entity/migrate/sqlite3/20240709-000001.pre.sql @@ -0,0 +1 @@ +ALTER TABLE auth_sessions RENAME COLUMN auth_domain TO auth_issuer; \ No newline at end of file diff --git a/internal/form/user.go b/internal/form/user.go index aeefc3db1..e245b2c1d 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -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")), diff --git a/pkg/authn/errors.go b/pkg/authn/errors.go index add4d2e97..bce7ed73a 100644 --- a/pkg/authn/errors.go +++ b/pkg/authn/errors.go @@ -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") diff --git a/pkg/authn/issuer.go b/pkg/authn/issuer.go new file mode 100644 index 000000000..34e85bd4d --- /dev/null +++ b/pkg/authn/issuer.go @@ -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) +} diff --git a/pkg/authn/issuer_test.go b/pkg/authn/issuer_test.go new file mode 100644 index 000000000..79ee7f71f --- /dev/null +++ b/pkg/authn/issuer_test.go @@ -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 ")) +} diff --git a/pkg/authn/providers.go b/pkg/authn/providers.go index 32e22a089..cbdaf867f 100644 --- a/pkg/authn/providers.go +++ b/pkg/authn/providers.go @@ -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))