From cc38922cbe2611ddd87142e90e12300cd29ed0a5 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 22 Nov 2022 22:14:34 +0100 Subject: [PATCH] Auth: Add dummy LDAP service #98 Signed-off-by: Michael Mayer --- docker-compose.ci.yml | 4 +- docker-compose.latest.yml | 4 +- docker-compose.local.yml | 4 +- docker-compose.postgres.yml | 4 +- docker-compose.yml | 52 ++- frontend/src/common/config.js | 2 + frontend/src/common/session.js | 4 + frontend/src/css/layout.css | 24 ++ frontend/src/dialog/account/password.vue | 5 +- frontend/src/dialog/photo/edit/details.vue | 2 +- frontend/src/page/login.vue | 20 +- frontend/src/page/settings/account.vue | 2 +- go.mod | 4 + go.sum | 8 + internal/api/auth_session_create.go | 22 +- internal/api/auth_session_get.go | 11 +- internal/api/config_settings.go | 4 +- internal/commands/users_add.go | 6 +- internal/commands/users_list.go | 5 +- internal/config/client_config.go | 424 +++++++++++---------- internal/config/client_config_test.go | 4 + internal/config/config.go | 5 +- internal/config/config_auth.go | 51 ++- internal/config/config_auth_test.go | 38 +- internal/config/config_server.go | 2 +- internal/config/flags.go | 16 +- internal/config/options.go | 4 + internal/config/report.go | 4 + internal/entity/auth_session.go | 20 + internal/entity/auth_session_login.go | 99 +++-- internal/entity/auth_user.go | 41 +- internal/entity/auth_user_add.go | 4 +- internal/entity/auth_user_test.go | 11 +- internal/entity/entity_const.go | 6 + internal/query/photo.go | 3 +- internal/server/routes_static.go | 5 +- 36 files changed, 587 insertions(+), 337 deletions(-) diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 5eee1d47f..ff27a281b 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -19,8 +19,8 @@ services: - "~/.cache/go-mod:/go/pkg/mod" environment: PHOTOPRISM_INIT: "https" - PHOTOPRISM_ADMIN_USER: "admin" # admin username - PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters) + PHOTOPRISM_ADMIN_USER: "admin" # superadmin username + PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters) PHOTOPRISM_AUTH_MODE: "public" # authentication mode (public, password) PHOTOPRISM_SITE_URL: "http://photoprism.me:2342/" PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App" diff --git a/docker-compose.latest.yml b/docker-compose.latest.yml index 7ab84c28b..a057cb13c 100644 --- a/docker-compose.latest.yml +++ b/docker-compose.latest.yml @@ -24,8 +24,8 @@ services: PHOTOPRISM_INIT: "https" PHOTOPRISM_UID: ${UID:-1000} # user id, should match your host user id PHOTOPRISM_GID: ${GID:-1000} # group id - PHOTOPRISM_ADMIN_USER: "admin" # admin username - PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters) + PHOTOPRISM_ADMIN_USER: "admin" # superadmin username + PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters) PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password) PHOTOPRISM_SITE_URL: "https://latest.localssl.dev/" # server URL in the format "http(s)://domain.name(:port)/(path)" PHOTOPRISM_SITE_CAPTION: "Latest" diff --git a/docker-compose.local.yml b/docker-compose.local.yml index ef9d3161a..6d462cf78 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -24,8 +24,8 @@ services: environment: PHOTOPRISM_UID: ${UID:-1000} # user id, should match your host user id PHOTOPRISM_GID: ${GID:-1000} # group id - PHOTOPRISM_ADMIN_USER: "admin" # admin username - PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters) + PHOTOPRISM_ADMIN_USER: "admin" # superadmin username + PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters) PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password) PHOTOPRISM_SITE_URL: "https://latest.localssl.dev/" # server URL in the format "http(s)://domain.name(:port)/(path)" PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App" diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml index 5e55c29b4..f542543cd 100644 --- a/docker-compose.postgres.yml +++ b/docker-compose.postgres.yml @@ -27,8 +27,8 @@ services: shm_size: "2gb" environment: PHOTOPRISM_INIT: "https" - PHOTOPRISM_ADMIN_USER: "admin" # admin username - PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters) + PHOTOPRISM_ADMIN_USER: "admin" # superadmin username + PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters) PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password) PHOTOPRISM_SITE_URL: "http://photoprism.me:2342/" PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App" diff --git a/docker-compose.yml b/docker-compose.yml index 487afd270..c997397d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,8 +24,8 @@ services: - "traefik:localssl.dev" - "traefik:app.localssl.dev" - "traefik:keycloak.localssl.dev" - - "traefik:dummy-webdav.localssl.dev" - "traefik:dummy-oidc.localssl.dev" + - "traefik:dummy-webdav.localssl.dev" labels: - "traefik.enable=true" - "traefik.http.services.photoprism.loadbalancer.server.port=2342" @@ -39,9 +39,26 @@ services: ## Run as a non-root user after initialization (supported: 0, 33, 50-99, 500-600, and 900-1200): PHOTOPRISM_UID: ${UID:-1000} # user id, should match your host user id PHOTOPRISM_GID: ${GID:-1000} # group id - PHOTOPRISM_ADMIN_USER: "admin" # admin username - PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters) + ## Access Management + PHOTOPRISM_ADMIN_USER: "admin" # superadmin username + PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters) PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password) + PHOTOPRISM_REGISTER_URI: "https://keycloak.localssl.dev/admin/" + PHOTOPRISM_PASSWORD_RESET_URI: "https://keycloak.localssl.dev/realms/master/login-actions/reset-credentials" + ## LDAP Authentication (pre-configured for local tests): + PHOTOPRISM_LDAP_URI: "ldaps://dummy-ldap:1636" + PHOTOPRISM_LDAP_INSECURE: "true" + PHOTOPRISM_LDAP_ROLE: "user" + PHOTOPRISM_LDAP_WEBDAV: "true" + PHOTOPRISM_LDAP_BIND: "simple" + PHOTOPRISM_LDAP_BIND_DN: "cn" + PHOTOPRISM_LDAP_BASE_DN: "dc=localssl,dc=dev" + ## OpenID Connect (pre-configured for local tests): + PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/auth/realms/master" + PHOTOPRISM_OIDC_INSECURE: "true" + PHOTOPRISM_OIDC_CLIENT: "photoprism-develop" + PHOTOPRISM_OIDC_SECRET: "9d8351a0-ca01-4556-9c37-85eb634869b9" + ## Site Information PHOTOPRISM_SITE_URL: "http://photoprism.me:2342/" # server URL in the format "http(s)://domain.name(:port)/(path)" PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App" PHOTOPRISM_SITE_DESCRIPTION: "Tags and finds pictures without getting in your way!" @@ -83,10 +100,6 @@ services: PHOTOPRISM_JPEG_SIZE: 7680 # size limit for converted image files in pixels (720-30000) PHOTOPRISM_JPEG_QUALITY: 85 # a higher value increases the quality and file size of JPEG images and thumbnails (25-100) TF_CPP_MIN_LOG_LEVEL: 0 # show TensorFlow log messages for development - ## OpenID Connect Provider (pre-configured for local Keycloak test server): - PHOTOPRISM_OIDC_ISSUER_URL: "https://keycloak.localssl.dev/auth/realms/master" - PHOTOPRISM_OIDC_CLIENT_ID: "photoprism-develop" - PHOTOPRISM_OIDC_CLIENT_SECRET: "9d8351a0-ca01-4556-9c37-85eb634869b9" ## Run/install on first startup (options: update https gpu tensorflow davfs clitools clean): PHOTOPRISM_INIT: "https tensorflow" ## Hardware Video Transcoding (optional): @@ -151,6 +164,7 @@ services: keycloak: image: quay.io/keycloak/keycloak:19.0 command: "start-dev" # development mode, do not use this in production! + container_name: keycloak links: - "traefik:localssl.dev" - "traefik:app.localssl.dev" @@ -174,9 +188,32 @@ services: KC_DB_USERNAME: "keycloak" KC_DB_PASSWORD: "keycloak" + ## Dummy LDAP Server + dummy-ldap: + image: openidentityplatform/opendj:latest + container_name: dummy-ldap + expose: + - 1389 + - 1636 + - 4444 + # ports: + # - "1389:1389" + # - "1636:1636" + # - "4444:4444" + user: "1001:1000" + environment: + OPENDJ_USER: 1001 + PORT: 1389 + LDAPS_PORT: 1636 + BASE_DN: "dc=localssl,dc=dev" + ADD_BASE_ENTRY: "--addBaseEntry" + ROOT_USER_DN: "cn=user" + ROOT_PASSWORD: "photoprism" + ## Dummy OpenID Connect Provider dummy-oidc: image: photoprism/dummy-oidc:220405 + container_name: dummy-oidc labels: - "traefik.enable=true" - "traefik.http.services.dummy-oidc.loadbalancer.server.port=9998" @@ -189,6 +226,7 @@ services: ## Dummy WebDAV Server dummy-webdav: image: photoprism/dummy-webdav:220405 + container_name: dummy-webdav environment: WEBDAV_USERNAME: admin WEBDAV_PASSWORD: photoprism diff --git a/frontend/src/common/config.js b/frontend/src/common/config.js index 7f56a33c5..0b12488bf 100644 --- a/frontend/src/common/config.js +++ b/frontend/src/common/config.js @@ -61,6 +61,7 @@ export default class Config { this.themeName = ""; this.baseUri = ""; this.staticUri = "/static"; + this.loginUri = "/library/login"; this.apiUri = "/api/v1"; this.contentUri = this.apiUri; this.values = { @@ -75,6 +76,7 @@ export default class Config { } else { this.baseUri = values.baseUri ? values.baseUri : ""; this.staticUri = values.staticUri ? values.staticUri : this.baseUri + "/static"; + this.loginUri = values.loginUri ? values.loginUri : this.baseUri + "/library/login"; this.apiUri = values.apiUri ? values.apiUri : this.baseUri + "/api/v1"; this.contentUri = values.contentUri ? values.contentUri : this.apiUri; } diff --git a/frontend/src/common/session.js b/frontend/src/common/session.js index dd62c6899..321a9e969 100644 --- a/frontend/src/common/session.js +++ b/frontend/src/common/session.js @@ -142,6 +142,7 @@ export default class Session { deleteId() { this.session_id = null; + this.provider = ""; this.storage.removeItem("session_id"); delete Api.defaults.headers.common[SessionHeader]; @@ -157,6 +158,9 @@ export default class Session { if (resp.data.id) { this.setId(resp.data.id); } + if (resp.data.provider) { + this.provider = resp.data.provider; + } if (resp.data.config) { this.setConfig(resp.data.config); } diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css index 6e3339714..ae291702d 100644 --- a/frontend/src/css/layout.css +++ b/frontend/src/css/layout.css @@ -44,6 +44,30 @@ max-width: 1264px; } +.width-50 { + min-width: 50%; +} + +.width-60 { + min-width: 60%; +} + +.width-66 { + min-width: 66%; +} + +.width-70 { + min-width: 70%; +} + +.width-80 { + min-width: 80%; +} + +.width-90 { + min-width: 90%; +} + /* Rounded Elements */ .v-progress-linear, diff --git a/frontend/src/dialog/account/password.vue b/frontend/src/dialog/account/password.vue index 0495b9587..02480385e 100644 --- a/frontend/src/dialog/account/password.vue +++ b/frontend/src/dialog/account/password.vue @@ -44,7 +44,7 @@ :label="$gettext('New Password')" class="input-new-password" color="secondary-dark" - :hint="$gettext('Must have at least 8 characters.')" + :hint="$gettextInterpolate($gettext('Must have at least %{n} characters.'), {n: passwordLength})" > @@ -101,6 +101,7 @@ export default { oldPassword: "", newPassword: "", confirmPassword: "", + passwordLength: this.$config.get("passwordLength"), rtl: this.$rtl, }; }, @@ -112,7 +113,7 @@ export default { }, methods: { disabled() { - return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < 8 || (this.newPassword !== this.confirmPassword)); + return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < this.passwordLength || (this.newPassword !== this.confirmPassword)); }, confirm() { this.busy = true; diff --git a/frontend/src/dialog/photo/edit/details.vue b/frontend/src/dialog/photo/edit/details.vue index 709b9421c..18f0ea340 100644 --- a/frontend/src/dialog/photo/edit/details.vue +++ b/frontend/src/dialog/photo/edit/details.vue @@ -31,7 +31,7 @@ :disabled="disabled" :rules="[textRule]" hide-details box flat - :label="$gettext('Title')" + :label="$pgettext('Photo', 'Title')" placeholder="" color="secondary-dark" browser-autocomplete="off" diff --git a/frontend/src/page/login.vue b/frontend/src/page/login.vue index 6845b2915..aa40e3cca 100644 --- a/frontend/src/page/login.vue +++ b/frontend/src/page/login.vue @@ -45,13 +45,19 @@ >
- - + + Create Account + + Sign in arrow_forward
+ @@ -103,7 +109,8 @@ export default { return { colors: { accent: "#05dde1", - primary: "#00adb0", + primary: "#00a6a9", + secondary: "#505050", link: "#c8e3e7", }, loading: false, @@ -115,6 +122,8 @@ export default { siteDescription: this.$config.getSiteDescription(), nextUrl: this.$route.params.nextUrl ? this.$route.params.nextUrl : "/", wallpaperUri: this.$config.values.wallpaperUri, + registerUri: this.$config.values.registerUri, + passwordResetUri: this.$config.values.passwordResetUri, rtl: this.$rtl, }; }, @@ -146,6 +155,9 @@ export default { setTimeout(() => { window.location = route.href; }, 100); }, + register() { + window.location = this.registerUri; + }, login() { const username = this.username.trim(); const password = this.password.trim(); diff --git a/frontend/src/page/settings/account.vue b/frontend/src/page/settings/account.vue index deb9777c3..d0ad05232 100644 --- a/frontend/src/page/settings/account.vue +++ b/frontend/src/page/settings/account.vue @@ -31,7 +31,7 @@ browser-autocomplete="off" autocorrect="off" autocapitalize="none" - :label="$gettext('Title')" + :label="$pgettext('Account', 'Title')" class="input-name-title" color="secondary-dark" :rules="[v => validLength(v, 0, 32) || $gettext('Invalid')]" diff --git a/go.mod b/go.mod index f906a74ca..cb4ccef9d 100644 --- a/go.mod +++ b/go.mod @@ -97,6 +97,8 @@ require ( golang.org/x/time v0.2.0 ) +require github.com/go-ldap/ldap/v3 v3.4.4 + require ( github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 // indirect @@ -105,6 +107,7 @@ require ( github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/cenkalti/backoff/v4 v4.1.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -112,6 +115,7 @@ require ( github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect diff --git a/go.sum b/go.sum index 1118de13c..6218758c7 100644 --- a/go.sum +++ b/go.sum @@ -202,6 +202,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU= +github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= @@ -382,6 +384,8 @@ github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/go-acme/lego/v4 v4.9.0 h1:8Hjj44IqRS7cigshMyFQ+0pIZvwgkG/+9A0UnNh7G8A= github.com/go-acme/lego/v4 v4.9.0/go.mod h1:g3JRUyWS3L/VObpp4bCxzJftKyf/Wba8QrSSnoiqjg4= +github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= +github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= @@ -405,6 +409,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= +github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs= +github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -918,6 +924,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= @@ -1022,6 +1029,7 @@ golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= diff --git a/internal/api/auth_session_create.go b/internal/api/auth_session_create.go index 4df5be71e..31b6f3c53 100644 --- a/internal/api/auth_session_create.go +++ b/internal/api/auth_session_create.go @@ -32,11 +32,12 @@ func CreateSession(router *gin.RouterGroup) { if conf.Public() { sess := get.Session().Public() data := gin.H{ - "status": "ok", - "id": sess.ID, - "user": sess.User(), - "data": sess.Data(), - "config": conf.ClientPublic(), + "status": "ok", + "id": sess.ID, + "provider": sess.AuthProvider, + "user": sess.User(), + "data": sess.Data(), + "config": conf.ClientPublic(), } c.JSON(http.StatusOK, data) return @@ -86,11 +87,12 @@ func CreateSession(router *gin.RouterGroup) { // User information, session data, and client config values. data := gin.H{ - "status": "ok", - "id": sess.ID, - "user": sess.User(), - "data": sess.Data(), - "config": clientConfig, + "status": "ok", + "id": sess.ID, + "provider": sess.AuthProvider, + "user": sess.User(), + "data": sess.Data(), + "config": clientConfig, } // Send JSON response. diff --git a/internal/api/auth_session_get.go b/internal/api/auth_session_get.go index c3a043e48..ecd55d97f 100644 --- a/internal/api/auth_session_get.go +++ b/internal/api/auth_session_get.go @@ -55,11 +55,12 @@ func GetSession(router *gin.RouterGroup) { // Send JSON response with user information, session data, and client config values. data := gin.H{ - "status": "ok", - "id": sess.ID, - "user": sess.User(), - "data": sess.Data(), - "config": get.Config().ClientSession(sess), + "status": "ok", + "id": sess.ID, + "provider": sess.AuthProvider, + "user": sess.User(), + "data": sess.Data(), + "config": get.Config().ClientSession(sess), } c.JSON(http.StatusOK, data) diff --git a/internal/api/config_settings.go b/internal/api/config_settings.go index a6663c58d..30515bd1c 100644 --- a/internal/api/config_settings.go +++ b/internal/api/config_settings.go @@ -58,8 +58,8 @@ func SaveSettings(router *gin.RouterGroup) { var settings *customize.Settings - if s.User().IsAdmin() { - // Only admins may change the global config. + // Only super admins can change global config defaults. + if s.User().IsSuperAdmin() { settings = conf.Settings() if err := c.BindJSON(settings); err != nil { diff --git a/internal/commands/users_add.go b/internal/commands/users_add.go index df057cc98..8fbb110ad 100644 --- a/internal/commands/users_add.go +++ b/internal/commands/users_add.go @@ -64,10 +64,10 @@ func usersAddAction(ctx *cli.Context) error { frm.UserEmail = clean.Email(res) } - if interactive && len(ctx.String("password")) < entity.LenPasswordMin { + if interactive && len(ctx.String("password")) < entity.PasswordLength { validate := func(input string) error { - if len(input) < entity.LenPasswordMin { - return fmt.Errorf("password must have at least %d characters", entity.LenPasswordMin) + if len(input) < entity.PasswordLength { + return fmt.Errorf("password must have at least %d characters", entity.PasswordLength) } return nil } diff --git a/internal/commands/users_list.go b/internal/commands/users_list.go index 0ac1ad1d4..3568858e6 100644 --- a/internal/commands/users_list.go +++ b/internal/commands/users_list.go @@ -23,7 +23,7 @@ var UsersListCommand = cli.Command{ // usersListAction displays existing user accounts. func usersListAction(ctx *cli.Context) error { return CallWithDependencies(ctx, func(conf *config.Config) error { - cols := []string{"ID", "UID", "User Name", "Display Name", "Email", "Role", "Super Admin", "Web Login", "WebDAV", "Attributes", "Created At"} + cols := []string{"User", "Login", "Full Name", "Email", "Role", "Super Admin", "Web UI", "WebDAV", "Attributes", "Created At"} // Fetch users from database. users := query.RegisteredUsers() @@ -35,9 +35,8 @@ func usersListAction(ctx *cli.Context) error { // Display report. for i, user := range users { rows[i] = []string{ - fmt.Sprintf("%d", user.ID), user.UID(), - user.Name(), + user.Login(), user.FullName(), user.Email(), user.AclRole().String(), diff --git a/internal/config/client_config.go b/internal/config/client_config.go index f3de0d101..22d59f098 100644 --- a/internal/config/client_config.go +++ b/internal/config/client_config.go @@ -23,64 +23,68 @@ const ( // ClientConfig represents HTTP client / Web UI config options. type ClientConfig struct { - Mode string `json:"mode"` - Name string `json:"name"` - About string `json:"about"` - Edition string `json:"edition"` - Version string `json:"version"` - Copyright string `json:"copyright"` - Flags string `json:"flags"` - BaseUri string `json:"baseUri"` - StaticUri string `json:"staticUri"` - CssUri string `json:"cssUri"` - JsUri string `json:"jsUri"` - ManifestUri string `json:"manifestUri"` - ApiUri string `json:"apiUri"` - ContentUri string `json:"contentUri"` - WallpaperUri string `json:"wallpaperUri"` - SiteUrl string `json:"siteUrl"` - SiteDomain string `json:"siteDomain"` - SiteAuthor string `json:"siteAuthor"` - SiteTitle string `json:"siteTitle"` - SiteCaption string `json:"siteCaption"` - SiteDescription string `json:"siteDescription"` - SitePreview string `json:"sitePreview"` - LegalInfo string `json:"legalInfo"` - LegalUrl string `json:"legalUrl"` - AppName string `json:"appName"` - AppMode string `json:"appMode"` - AppIcon string `json:"appIcon"` - Debug bool `json:"debug"` - Trace bool `json:"trace"` - Test bool `json:"test"` - Demo bool `json:"demo"` - Sponsor bool `json:"sponsor"` - ReadOnly bool `json:"readonly"` - UploadNSFW bool `json:"uploadNSFW"` - Public bool `json:"public"` - AuthMode string `json:"authMode"` - Experimental bool `json:"experimental"` - AlbumCategories []string `json:"albumCategories"` - Albums entity.Albums `json:"albums"` - Cameras entity.Cameras `json:"cameras"` - Lenses entity.Lenses `json:"lenses"` - Countries entity.Countries `json:"countries"` - People entity.People `json:"people"` - Thumbs ThumbSizes `json:"thumbs"` - MapKey string `json:"mapKey"` - DownloadToken string `json:"downloadToken,omitempty"` - PreviewToken string `json:"previewToken,omitempty"` - Disable ClientDisable `json:"disable"` - Count ClientCounts `json:"count"` - Pos ClientPosition `json:"pos"` - Years Years `json:"years"` - Colors []map[string]string `json:"colors"` - Categories CategoryLabels `json:"categories"` - Clip int `json:"clip"` - Server env.Resources `json:"server"` - Settings *customize.Settings `json:"settings,omitempty"` - ACL acl.Grants `json:"acl,omitempty"` - Ext Values `json:"ext"` + Mode string `json:"mode"` + Name string `json:"name"` + About string `json:"about"` + Edition string `json:"edition"` + Version string `json:"version"` + Copyright string `json:"copyright"` + Flags string `json:"flags"` + BaseUri string `json:"baseUri"` + StaticUri string `json:"staticUri"` + CssUri string `json:"cssUri"` + JsUri string `json:"jsUri"` + ManifestUri string `json:"manifestUri"` + ApiUri string `json:"apiUri"` + ContentUri string `json:"contentUri"` + WallpaperUri string `json:"wallpaperUri"` + SiteUrl string `json:"siteUrl"` + SiteDomain string `json:"siteDomain"` + SiteAuthor string `json:"siteAuthor"` + SiteTitle string `json:"siteTitle"` + SiteCaption string `json:"siteCaption"` + SiteDescription string `json:"siteDescription"` + SitePreview string `json:"sitePreview"` + LegalInfo string `json:"legalInfo"` + LegalUrl string `json:"legalUrl"` + AppName string `json:"appName"` + AppMode string `json:"appMode"` + AppIcon string `json:"appIcon"` + Debug bool `json:"debug"` + Trace bool `json:"trace"` + Test bool `json:"test"` + Demo bool `json:"demo"` + Sponsor bool `json:"sponsor"` + ReadOnly bool `json:"readonly"` + UploadNSFW bool `json:"uploadNSFW"` + Public bool `json:"public"` + AuthMode string `json:"authMode"` + LoginUri string `json:"loginUri"` + RegisterUri string `json:"registerUri"` + PasswordLength int `json:"passwordLength"` + PasswordResetUri string `json:"passwordResetUri"` + Experimental bool `json:"experimental"` + AlbumCategories []string `json:"albumCategories"` + Albums entity.Albums `json:"albums"` + Cameras entity.Cameras `json:"cameras"` + Lenses entity.Lenses `json:"lenses"` + Countries entity.Countries `json:"countries"` + People entity.People `json:"people"` + Thumbs ThumbSizes `json:"thumbs"` + MapKey string `json:"mapKey"` + DownloadToken string `json:"downloadToken,omitempty"` + PreviewToken string `json:"previewToken,omitempty"` + Disable ClientDisable `json:"disable"` + Count ClientCounts `json:"count"` + Pos ClientPosition `json:"pos"` + Years Years `json:"years"` + Colors []map[string]string `json:"colors"` + Categories CategoryLabels `json:"categories"` + Clip int `json:"clip"` + Server env.Resources `json:"server"` + Settings *customize.Settings `json:"settings,omitempty"` + ACL acl.Grants `json:"acl,omitempty"` + Ext Values `json:"ext"` } // ApplyACL updates the client config values based on the ACL and Role provided. @@ -225,54 +229,57 @@ func (c *Config) ClientPublic() ClientConfig { Faces: true, Classification: true, }, - Flags: strings.Join(c.Flags(), " "), - Mode: string(ClientPublic), - Name: c.Name(), - About: c.Edition(), - Edition: c.Hub().Status, - BaseUri: c.BaseUri(""), - StaticUri: c.StaticUri(), - CssUri: a.AppCssUri(), - JsUri: a.AppJsUri(), - ApiUri: c.ApiUri(), - ContentUri: c.ContentUri(), - SiteUrl: c.SiteUrl(), - SiteDomain: c.SiteDomain(), - SiteAuthor: c.SiteAuthor(), - SiteTitle: c.SiteTitle(), - SiteCaption: c.SiteCaption(), - SiteDescription: c.SiteDescription(), - SitePreview: c.SitePreview(), - LegalInfo: c.LegalInfo(), - LegalUrl: c.LegalUrl(), - AppName: c.AppName(), - AppMode: c.AppMode(), - AppIcon: c.AppIcon(), - WallpaperUri: c.WallpaperUri(), - Version: c.Version(), - Copyright: c.Copyright(), - Debug: c.Debug(), - Trace: c.Trace(), - Test: c.Test(), - Demo: c.Demo(), - Sponsor: c.Sponsor(), - ReadOnly: c.ReadOnly(), - Public: c.Public(), - AuthMode: c.AuthMode(), - Experimental: c.Experimental(), - Albums: entity.Albums{}, - Cameras: entity.Cameras{}, - Lenses: entity.Lenses{}, - Countries: entity.Countries{}, - People: entity.People{}, - MapKey: "", - Thumbs: Thumbs, - Colors: colors.All.List(), - ManifestUri: c.ClientManifestUri(), - Clip: txt.ClipDefault, - PreviewToken: entity.TokenPublic, - DownloadToken: entity.TokenPublic, - Ext: ClientExt(c, ClientPublic), + Flags: strings.Join(c.Flags(), " "), + Mode: string(ClientPublic), + Name: c.Name(), + About: c.Edition(), + Edition: c.Hub().Status, + BaseUri: c.BaseUri(""), + StaticUri: c.StaticUri(), + CssUri: a.AppCssUri(), + JsUri: a.AppJsUri(), + ApiUri: c.ApiUri(), + ContentUri: c.ContentUri(), + SiteUrl: c.SiteUrl(), + SiteDomain: c.SiteDomain(), + SiteAuthor: c.SiteAuthor(), + SiteTitle: c.SiteTitle(), + SiteCaption: c.SiteCaption(), + SiteDescription: c.SiteDescription(), + SitePreview: c.SitePreview(), + LegalInfo: c.LegalInfo(), + LegalUrl: c.LegalUrl(), + AppName: c.AppName(), + AppMode: c.AppMode(), + AppIcon: c.AppIcon(), + WallpaperUri: c.WallpaperUri(), + Version: c.Version(), + Copyright: c.Copyright(), + Debug: c.Debug(), + Trace: c.Trace(), + Test: c.Test(), + Demo: c.Demo(), + Sponsor: c.Sponsor(), + ReadOnly: c.ReadOnly(), + Public: c.Public(), + AuthMode: c.AuthMode(), + LoginUri: c.LoginUri(), + RegisterUri: c.RegisterUri(), + PasswordResetUri: c.PasswordResetUri(), + Experimental: c.Experimental(), + Albums: entity.Albums{}, + Cameras: entity.Cameras{}, + Lenses: entity.Lenses{}, + Countries: entity.Countries{}, + People: entity.People{}, + MapKey: "", + Thumbs: Thumbs, + Colors: colors.All.List(), + ManifestUri: c.ClientManifestUri(), + Clip: txt.ClipDefault, + PreviewToken: entity.TokenPublic, + DownloadToken: entity.TokenPublic, + Ext: ClientExt(c, ClientPublic), } return cfg @@ -301,55 +308,58 @@ func (c *Config) ClientShare() ClientConfig { Faces: true, Classification: true, }, - Flags: strings.Join(c.Flags(), " "), - Mode: string(ClientShare), - Name: c.Name(), - About: c.Edition(), - Edition: c.Hub().Status, - BaseUri: c.BaseUri(""), - StaticUri: c.StaticUri(), - CssUri: a.AppCssUri(), - JsUri: a.ShareJsUri(), - ApiUri: c.ApiUri(), - ContentUri: c.ContentUri(), - SiteUrl: c.SiteUrl(), - SiteDomain: c.SiteDomain(), - SiteAuthor: c.SiteAuthor(), - SiteTitle: c.SiteTitle(), - SiteCaption: c.SiteCaption(), - SiteDescription: c.SiteDescription(), - SitePreview: c.SitePreview(), - LegalInfo: c.LegalInfo(), - LegalUrl: c.LegalUrl(), - AppName: c.AppName(), - AppMode: c.AppMode(), - AppIcon: c.AppIcon(), - WallpaperUri: c.WallpaperUri(), - Version: c.Version(), - Copyright: c.Copyright(), - Debug: c.Debug(), - Trace: c.Trace(), - Test: c.Test(), - Demo: c.Demo(), - Sponsor: c.Sponsor(), - ReadOnly: c.ReadOnly(), - UploadNSFW: c.UploadNSFW(), - Public: c.Public(), - AuthMode: c.AuthMode(), - Experimental: c.Experimental(), - Albums: entity.Albums{}, - Cameras: entity.Cameras{}, - Lenses: entity.Lenses{}, - Countries: entity.Countries{}, - People: entity.People{}, - Colors: colors.All.List(), - Thumbs: Thumbs, - MapKey: c.Hub().MapKey(), - DownloadToken: c.DownloadToken(), - PreviewToken: c.PreviewToken(), - ManifestUri: c.ClientManifestUri(), - Clip: txt.ClipDefault, - Ext: ClientExt(c, ClientShare), + Flags: strings.Join(c.Flags(), " "), + Mode: string(ClientShare), + Name: c.Name(), + About: c.Edition(), + Edition: c.Hub().Status, + BaseUri: c.BaseUri(""), + StaticUri: c.StaticUri(), + CssUri: a.AppCssUri(), + JsUri: a.ShareJsUri(), + ApiUri: c.ApiUri(), + ContentUri: c.ContentUri(), + SiteUrl: c.SiteUrl(), + SiteDomain: c.SiteDomain(), + SiteAuthor: c.SiteAuthor(), + SiteTitle: c.SiteTitle(), + SiteCaption: c.SiteCaption(), + SiteDescription: c.SiteDescription(), + SitePreview: c.SitePreview(), + LegalInfo: c.LegalInfo(), + LegalUrl: c.LegalUrl(), + AppName: c.AppName(), + AppMode: c.AppMode(), + AppIcon: c.AppIcon(), + WallpaperUri: c.WallpaperUri(), + Version: c.Version(), + Copyright: c.Copyright(), + Debug: c.Debug(), + Trace: c.Trace(), + Test: c.Test(), + Demo: c.Demo(), + Sponsor: c.Sponsor(), + ReadOnly: c.ReadOnly(), + UploadNSFW: c.UploadNSFW(), + Public: c.Public(), + AuthMode: c.AuthMode(), + LoginUri: c.LoginUri(), + RegisterUri: c.RegisterUri(), + PasswordResetUri: c.PasswordResetUri(), + Experimental: c.Experimental(), + Albums: entity.Albums{}, + Cameras: entity.Cameras{}, + Lenses: entity.Lenses{}, + Countries: entity.Countries{}, + People: entity.People{}, + Colors: colors.All.List(), + Thumbs: Thumbs, + MapKey: c.Hub().MapKey(), + DownloadToken: c.DownloadToken(), + PreviewToken: c.PreviewToken(), + ManifestUri: c.ClientManifestUri(), + Clip: txt.ClipDefault, + Ext: ClientExt(c, ClientShare), } return cfg @@ -383,56 +393,60 @@ func (c *Config) ClientUser(withSettings bool) ClientConfig { Faces: c.DisableFaces(), Classification: c.DisableClassification(), }, - Flags: strings.Join(c.Flags(), " "), - Mode: string(ClientUser), - Name: c.Name(), - About: c.Edition(), - Edition: c.Hub().Status, - BaseUri: c.BaseUri(""), - StaticUri: c.StaticUri(), - CssUri: a.AppCssUri(), - JsUri: a.AppJsUri(), - ApiUri: c.ApiUri(), - ContentUri: c.ContentUri(), - SiteUrl: c.SiteUrl(), - SiteDomain: c.SiteDomain(), - SiteAuthor: c.SiteAuthor(), - SiteTitle: c.SiteTitle(), - SiteCaption: c.SiteCaption(), - SiteDescription: c.SiteDescription(), - SitePreview: c.SitePreview(), - LegalInfo: c.LegalInfo(), - LegalUrl: c.LegalUrl(), - AppName: c.AppName(), - AppMode: c.AppMode(), - AppIcon: c.AppIcon(), - WallpaperUri: c.WallpaperUri(), - Version: c.Version(), - Copyright: c.Copyright(), - Debug: c.Debug(), - Trace: c.Trace(), - Test: c.Test(), - Demo: c.Demo(), - Sponsor: c.Sponsor(), - ReadOnly: c.ReadOnly(), - UploadNSFW: c.UploadNSFW(), - Public: c.Public(), - AuthMode: c.AuthMode(), - Experimental: c.Experimental(), - Albums: entity.Albums{}, - Cameras: entity.Cameras{}, - Lenses: entity.Lenses{}, - Countries: entity.Countries{}, - People: entity.People{}, - Colors: colors.All.List(), - Thumbs: Thumbs, - MapKey: c.Hub().MapKey(), - DownloadToken: c.DownloadToken(), - PreviewToken: c.PreviewToken(), - ManifestUri: c.ClientManifestUri(), - Clip: txt.ClipDefault, - Server: env.Info(), - Ext: ClientExt(c, ClientUser), + Flags: strings.Join(c.Flags(), " "), + Mode: string(ClientUser), + Name: c.Name(), + About: c.Edition(), + Edition: c.Hub().Status, + BaseUri: c.BaseUri(""), + StaticUri: c.StaticUri(), + CssUri: a.AppCssUri(), + JsUri: a.AppJsUri(), + ApiUri: c.ApiUri(), + ContentUri: c.ContentUri(), + SiteUrl: c.SiteUrl(), + SiteDomain: c.SiteDomain(), + SiteAuthor: c.SiteAuthor(), + SiteTitle: c.SiteTitle(), + SiteCaption: c.SiteCaption(), + SiteDescription: c.SiteDescription(), + SitePreview: c.SitePreview(), + LegalInfo: c.LegalInfo(), + LegalUrl: c.LegalUrl(), + AppName: c.AppName(), + AppMode: c.AppMode(), + AppIcon: c.AppIcon(), + WallpaperUri: c.WallpaperUri(), + Version: c.Version(), + Copyright: c.Copyright(), + Debug: c.Debug(), + Trace: c.Trace(), + Test: c.Test(), + Demo: c.Demo(), + Sponsor: c.Sponsor(), + ReadOnly: c.ReadOnly(), + UploadNSFW: c.UploadNSFW(), + Public: c.Public(), + AuthMode: c.AuthMode(), + LoginUri: c.LoginUri(), + RegisterUri: c.RegisterUri(), + PasswordLength: c.PasswordLength(), + PasswordResetUri: c.PasswordResetUri(), + Experimental: c.Experimental(), + Albums: entity.Albums{}, + Cameras: entity.Cameras{}, + Lenses: entity.Lenses{}, + Countries: entity.Countries{}, + People: entity.People{}, + Colors: colors.All.List(), + Thumbs: Thumbs, + MapKey: c.Hub().MapKey(), + DownloadToken: c.DownloadToken(), + PreviewToken: c.PreviewToken(), + ManifestUri: c.ClientManifestUri(), + Clip: txt.ClipDefault, + Server: env.Info(), + Ext: ClientExt(c, ClientUser), } hidePrivate := c.Settings().Features.Private diff --git a/internal/config/client_config_test.go b/internal/config/client_config_test.go index 15fbb9196..e1679f9a9 100644 --- a/internal/config/client_config_test.go +++ b/internal/config/client_config_test.go @@ -17,6 +17,10 @@ func TestConfig_ClientConfig(t *testing.T) { result := c.ClientPublic() assert.IsType(t, ClientConfig{}, result) assert.Equal(t, AuthModePublic, result.AuthMode) + assert.Equal(t, "", result.LoginUri) + assert.Equal(t, "", result.RegisterUri) + assert.Equal(t, 0, result.PasswordLength) + assert.Equal(t, "", result.PasswordResetUri) assert.Equal(t, true, result.Public) }) t.Run("TestErrorConfig", func(t *testing.T) { diff --git a/internal/config/config.go b/internal/config/config.go index a239ff56f..c21de8541 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -166,6 +166,9 @@ func (c *Config) Propagate() { places.UserAgent = c.UserAgent() entity.GeoApi = c.GeoApi() + // Set minimum password length. + entity.PasswordLength = c.PasswordLength() + // Set API preview and download default tokens. entity.PreviewToken.Set(c.PreviewToken(), entity.TokenConfig) entity.DownloadToken.Set(c.DownloadToken(), entity.TokenConfig) @@ -673,7 +676,7 @@ func (c *Config) AutoImport() time.Duration { return time.Duration(c.options.AutoImport) * time.Second } -// GeoApi returns the preferred geocoding api (none or places). +// GeoApi returns the preferred geocoding api (places, or none). func (c *Config) GeoApi() string { if c.options.DisablePlaces { return "" diff --git a/internal/config/config_auth.go b/internal/config/config_auth.go index 1399e1e7a..3a8adb60b 100644 --- a/internal/config/config_auth.go +++ b/internal/config/config_auth.go @@ -3,10 +3,9 @@ package config import ( "regexp" - "github.com/photoprism/photoprism/internal/entity" - "golang.org/x/crypto/bcrypt" + "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/rnd" ) @@ -85,6 +84,11 @@ func (c *Config) SetAuthMode(mode string) { } } +// Auth checks if authentication is required. +func (c *Config) Auth() bool { + return !c.Public() +} + // AuthMode returns the authentication mode. func (c *Config) AuthMode() string { if c.options.Public || c.options.Demo { @@ -99,9 +103,46 @@ func (c *Config) AuthMode() string { } } -// Auth checks if authentication is required. -func (c *Config) Auth() bool { - return !c.Public() +// LoginUri returns the user login URI. +func (c *Config) LoginUri() string { + if c.Public() { + return "" + } + + if c.options.LoginUri == "" { + return c.BaseUri("/library/login") + } + + return c.options.LoginUri +} + +// RegisterUri returns the user registration URI. +func (c *Config) RegisterUri() string { + if c.Public() { + return "" + } + + return c.options.RegisterUri +} + +// PasswordLength returns the minimum password length in characters. +func (c *Config) PasswordLength() int { + if c.Public() { + return 0 + } else if c.options.PasswordLength < 1 { + return 4 + } + + return c.options.PasswordLength +} + +// PasswordResetUri returns the password reset URI. +func (c *Config) PasswordResetUri() string { + if c.Public() { + return "" + } + + return c.options.PasswordResetUri } // CheckPassword compares given password p with the admin password diff --git a/internal/config/config_auth_test.go b/internal/config/config_auth_test.go index 0eb2a52dc..af87abd77 100644 --- a/internal/config/config_auth_test.go +++ b/internal/config/config_auth_test.go @@ -6,6 +6,18 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAuth(t *testing.T) { + c := NewConfig(CliTestContext()) + c.options.Public = true + c.options.Demo = false + assert.False(t, c.Auth()) + c.options.Public = false + c.options.Demo = false + assert.True(t, c.Auth()) + c.options.Demo = true + assert.False(t, c.Auth()) +} + func TestAuthMode(t *testing.T) { c := NewConfig(CliTestContext()) c.options.Public = true @@ -34,16 +46,24 @@ func TestAuthMode(t *testing.T) { c.options.Debug = false } -func TestAuth(t *testing.T) { +func TestLoginUri(t *testing.T) { c := NewConfig(CliTestContext()) - c.options.Public = true - c.options.Demo = false - assert.False(t, c.Auth()) - c.options.Public = false - c.options.Demo = false - assert.True(t, c.Auth()) - c.options.Demo = true - assert.False(t, c.Auth()) + assert.Equal(t, "/library/login", c.LoginUri()) +} + +func TestRegisterUri(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, "", c.RegisterUri()) +} + +func TestPasswordLength(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, 4, c.PasswordLength()) +} + +func TestPasswordResetUri(t *testing.T) { + c := NewConfig(CliTestContext()) + assert.Equal(t, "", c.PasswordResetUri()) } func TestSessMaxAge(t *testing.T) { diff --git a/internal/config/config_server.go b/internal/config/config_server.go index ff125f8bc..928dead23 100644 --- a/internal/config/config_server.go +++ b/internal/config/config_server.go @@ -77,7 +77,7 @@ func (c *Config) HttpMode() string { return c.options.HttpMode } -// HttpCompression returns the http compression method (none or gzip). +// HttpCompression returns the http compression method (gzip, or none). func (c *Config) HttpCompression() string { return strings.ToLower(strings.TrimSpace(c.options.HttpCompression)) } diff --git a/internal/config/flags.go b/internal/config/flags.go index 9a9a3ce2d..7c69f82d7 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -1,9 +1,12 @@ package config import ( + "fmt" + "github.com/klauspost/cpuid/v2" "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/face" "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/server/header" @@ -27,13 +30,13 @@ var Flags = CliFlags{ }}, { Flag: cli.StringFlag{ Name: "admin-user, login", - Usage: "admin login `USERNAME`", + Usage: "superadmin `USERNAME`", EnvVar: "PHOTOPRISM_ADMIN_USER", Value: "admin", }}, { Flag: cli.StringFlag{ Name: "admin-password, pw", - Usage: "initial admin `PASSWORD`, must have at least 8 characters", + Usage: fmt.Sprintf("initial superadmin `PASSWORD` (minimum %d characters)", entity.PasswordLength), EnvVar: "PHOTOPRISM_ADMIN_PASSWORD", }}, { Flag: cli.Int64Flag{ @@ -126,10 +129,7 @@ var Flags = CliFlags{ Value: DefaultResolutionLimit, Usage: "maximum resolution of media files in `MEGAPIXELS` (1-900; -1 to disable)", EnvVar: "PHOTOPRISM_RESOLUTION_LIMIT", - }, - Tags: []string{EnvSponsor}, - }, - { + }, Tags: []string{EnvSponsor}}, { Flag: cli.StringFlag{ Name: "storage-path, s", Usage: "writable storage `PATH` for sidecar, cache, and database files", @@ -427,12 +427,12 @@ var Flags = CliFlags{ }}, { Flag: cli.StringFlag{ Name: "http-mode, mode", - Usage: "Web server `MODE` (debug, release, or test)", + Usage: "Web server `MODE` (debug, release, test)", EnvVar: "PHOTOPRISM_HTTP_MODE", }}, { Flag: cli.StringFlag{ Name: "http-compression, z", - Usage: "Web server compression `METHOD` (none or gzip)", + Usage: "Web server compression `METHOD` (gzip, none)", EnvVar: "PHOTOPRISM_HTTP_COMPRESSION", }}, { Flag: cli.StringFlag{ diff --git a/internal/config/options.go b/internal/config/options.go index b2f7099c5..67e3f59a7 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -23,6 +23,10 @@ type Options struct { Copyright string `json:"-"` PartnerID string `yaml:"-" json:"-" flag:"partner-id"` AuthMode string `yaml:"AuthMode" json:"-" flag:"auth-mode"` + LoginUri string `yaml:"LoginUri" json:"-" flag:"login-uri"` + RegisterUri string `yaml:"RegisterUri" json:"-" flag:"register-uri"` + PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"` + PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri"` Public bool `yaml:"Public" json:"-" flag:"public"` AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"` AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"` diff --git a/internal/config/report.go b/internal/config/report.go index 73f020d90..3fd220ed5 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -27,6 +27,10 @@ func (c *Config) Report() (rows [][]string, cols []string) { {"public", fmt.Sprintf("%t", c.Public())}, {"session-maxage", fmt.Sprintf("%d", c.SessionMaxAge())}, {"session-timeout", fmt.Sprintf("%d", c.SessionTimeout())}, + {"login-uri", c.LoginUri()}, + {"register-uri", c.RegisterUri()}, + {"password-length", fmt.Sprintf("%d", c.PasswordLength())}, + {"password-reset-uri", c.PasswordResetUri()}, // Logging. {"log-level", c.LogLevel().String()}, diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 0ed0e4b4a..b8d23e9c0 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -257,6 +257,26 @@ func (m *Session) SetUser(u *User) *Session { return m } +// Login returns the login name and provider. +func (m *Session) Login() string { + if m.AuthProvider == "" { + return m.UserName + } else { + return fmt.Sprintf("%s@%s", m.UserName, m.AuthProvider) + } +} + +// SetProvider updates the session's authentication provider. +func (m *Session) SetProvider(provider string) *Session { + if provider == "" { + return m + } + + m.AuthProvider = provider + + return m +} + // ChangePassword changes the password of the current user. func (m *Session) ChangePassword(newPw string) (err error) { u := m.User() diff --git a/internal/entity/auth_session_login.go b/internal/entity/auth_session_login.go index cc742849a..9245651ab 100644 --- a/internal/entity/auth_session_login.go +++ b/internal/entity/auth_session_login.go @@ -4,8 +4,6 @@ import ( "net/http" "time" - "github.com/photoprism/photoprism/pkg/txt" - "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/event" @@ -13,61 +11,90 @@ import ( "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/server/limiter" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/txt" ) +// Auth checks if the credentials are valid and returns the user and authentication provider. +var Auth = func(f form.Login, m *Session, c *gin.Context) (user *User, provider string, err error) { + name := f.Name() + + user = FindUserByName(name) + err = AuthPassword(user, f, m) + + if err != nil { + return user, ProviderNone, err + } + + return user, ProviderPassword, err +} + +// AuthPassword checks if the username and password are valid and returns the user. +func AuthPassword(user *User, f form.Login, m *Session) (err error) { + name := f.Name() + + // User found? + if user == nil { + message := "account not found" + limiter.Login.Reserve(m.IP()) + event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name)) + event.LoginError(m.IP(), "api", name, m.UserAgent, message) + m.Status = http.StatusUnauthorized + return i18n.Error(i18n.ErrInvalidCredentials) + } + + // Login allowed? + if !user.CanLogIn() { + message := "account disabled" + event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name)) + event.LoginError(m.IP(), "api", name, m.UserAgent, message) + m.Status = http.StatusUnauthorized + return i18n.Error(i18n.ErrInvalidCredentials) + } + + // Password valid? + if user.WrongPassword(f.Password) { + message := "incorrect password" + limiter.Login.Reserve(m.IP()) + event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name)) + event.LoginError(m.IP(), "api", name, m.UserAgent, message) + m.Status = http.StatusUnauthorized + return i18n.Error(i18n.ErrInvalidCredentials) + } else { + event.AuditInfo([]string{m.IP(), "session %s", "login as %s", "succeeded"}, m.RefID, clean.LogQuote(name)) + event.LoginInfo(m.IP(), "api", name, m.UserAgent) + } + + return err +} + // LogIn performs authentication checks against the specified login form. func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) { if c != nil { m.SetContext(c) } - // Username and password provided? + var user *User + var provider string + + // Login credentials provided? if f.HasCredentials() { if m.IsRegistered() { m.RegenerateID() } - name := f.Name() - user := FindUserByName(name) + user, provider, err = Auth(f, m, c) - // User found? - if user == nil { - message := "account not found" - limiter.Login.Reserve(m.IP()) - event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name)) - event.LoginError(m.IP(), "api", name, m.UserAgent, message) - m.Status = http.StatusUnauthorized - return i18n.Error(i18n.ErrInvalidCredentials) - } - - // Login allowed? - if !user.CanLogIn() { - message := "account disabled" - event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name)) - event.LoginError(m.IP(), "api", name, m.UserAgent, message) - m.Status = http.StatusUnauthorized - return i18n.Error(i18n.ErrInvalidCredentials) - } - - // Password valid? - if user.WrongPassword(f.Password) { - message := "incorrect password" - limiter.Login.Reserve(m.IP()) - event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name)) - event.LoginError(m.IP(), "api", name, m.UserAgent, message) - m.Status = http.StatusUnauthorized - return i18n.Error(i18n.ErrInvalidCredentials) - } else { - event.AuditInfo([]string{m.IP(), "session %s", "login as %s", "succeeded"}, m.RefID, clean.LogQuote(name)) - event.LoginInfo(m.IP(), "api", name, m.UserAgent) + if err != nil { + return err } m.SetUser(user) + m.SetProvider(provider) } // Share token provided? if f.HasToken() { - user := m.User() + user = m.User() // Redeem token. if user.IsRegistered() { diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go index 449986daa..85237b3a8 100644 --- a/internal/entity/auth_user.go +++ b/internal/entity/auth_user.go @@ -8,9 +8,8 @@ import ( "strings" "time" - "github.com/ulule/deepcopier" - "github.com/jinzhu/gorm" + "github.com/ulule/deepcopier" "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/event" @@ -27,11 +26,11 @@ const ( OwnerUnknown = "" ) -// LenNameMin specifies the minimum length of the username in characters. -var LenNameMin = 3 +// UsernameLength specifies the minimum length of the username in characters. +var UsernameLength = 1 -// LenPasswordMin specifies the minimum length of the password in characters. -var LenPasswordMin = 4 +// PasswordLength specifies the minimum length of the password in characters. +var PasswordLength = 4 // Users represents a list of users. type Users []User @@ -110,6 +109,8 @@ func FindUser(find User) *User { stmt = stmt.Where("user_name = ?", find.UserName) } else if find.UserEmail != "" { stmt = stmt.Where("user_email = ?", find.UserEmail) + } else if find.AuthProvider != "" && find.AuthID != "" { + stmt = stmt.Where("auth_provider = ? AND auth_id = ?", find.AuthProvider, find.AuthID) } else { return nil } @@ -512,7 +513,16 @@ func (m *User) IsAdmin() bool { return false } - return m.IsRegistered() && (m.SuperAdmin || m.AclRole() == acl.RoleAdmin) + return m.IsSuperAdmin() || m.IsRegistered() && m.AclRole() == acl.RoleAdmin +} + +// IsSuperAdmin checks if the user is a super admin. +func (m *User) IsSuperAdmin() bool { + if m == nil { + return false + } + + return m.SuperAdmin } // IsVisitor checks if the user is a sharing link visitor. @@ -559,8 +569,8 @@ func (m *User) SetPassword(password string) error { return fmt.Errorf("only registered users can change their password") } - if len(password) < LenPasswordMin { - return fmt.Errorf("password must have at least %d characters", LenPasswordMin) + if len(password) < PasswordLength { + return fmt.Errorf("password must have at least %d characters", PasswordLength) } pw := NewPassword(m.UserUID, password) @@ -614,8 +624,8 @@ func (m *User) Validate() (err error) { } // Name too short? - if len(m.Name()) < LenNameMin { - return fmt.Errorf("username must have at least %d characters", LenNameMin) + if len(m.Name()) < UsernameLength { + return fmt.Errorf("username must have at least %d characters", UsernameLength) } // Validate user role. @@ -867,3 +877,12 @@ func (m *User) SetAvatar(thumb, thumbSrc string) error { return m.Updates(Values{"Thumb": m.Thumb, "ThumbSrc": m.ThumbSrc}) } + +// Login returns the login name and provider. +func (m *User) Login() string { + if m.AuthProvider == "" { + return m.UserName + } else { + return fmt.Sprintf("%s@%s", m.UserName, m.AuthProvider) + } +} diff --git a/internal/entity/auth_user_add.go b/internal/entity/auth_user_add.go index 50617078d..17abeb68f 100644 --- a/internal/entity/auth_user_add.go +++ b/internal/entity/auth_user_add.go @@ -13,8 +13,8 @@ import ( func AddUser(frm form.User) error { user := NewUser().SetFormValues(frm) - if len(frm.Password) < LenPasswordMin { - return fmt.Errorf("password must have at least %d characters", LenPasswordMin) + if len(frm.Password) < PasswordLength { + return fmt.Errorf("password must have at least %d characters", PasswordLength) } if err := user.Validate(); err != nil { diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go index d5058dcb2..6f8bca592 100644 --- a/internal/entity/auth_user_test.go +++ b/internal/entity/auth_user_test.go @@ -333,6 +333,7 @@ func TestFindUserByUID(t *testing.T) { assert.Equal(t, "alice@example.com", m.UserEmail) assert.True(t, m.SuperAdmin) assert.True(t, m.IsAdmin()) + assert.True(t, m.IsSuperAdmin()) assert.False(t, m.IsVisitor()) assert.True(t, m.CanLogin) assert.NotEmpty(t, m.CreatedAt) @@ -352,6 +353,7 @@ func TestFindUserByUID(t *testing.T) { assert.Equal(t, "bob@example.com", m.UserEmail) assert.False(t, m.SuperAdmin) assert.True(t, m.IsAdmin()) + assert.False(t, m.IsSuperAdmin()) assert.False(t, m.IsVisitor()) assert.True(t, m.CanLogin) assert.NotEmpty(t, m.CreatedAt) @@ -537,15 +539,6 @@ func TestUser_Validate(t *testing.T) { assert.Error(t, u.Validate()) }) - t.Run("NameTooShort", func(t *testing.T) { - u := &User{ - UserName: "va", - DisplayName: "Validate", - UserEmail: "validate@example.com", - UserRole: acl.RoleAdmin.String(), - } - assert.Error(t, u.Validate()) - }) t.Run("NameNotUnique", func(t *testing.T) { FirstOrCreateUser(&User{ UserName: "notunique1", diff --git a/internal/entity/entity_const.go b/internal/entity/entity_const.go index 85babd375..f53b595a7 100644 --- a/internal/entity/entity_const.go +++ b/internal/entity/entity_const.go @@ -52,6 +52,12 @@ const ( IsUnstacked int8 = -1 ) +// Authentication providers. +const ( + ProviderNone = "" + ProviderPassword = "password" +) + // Sort options. const ( SortOrderDefault = "" diff --git a/internal/query/photo.go b/internal/query/photo.go index ec6e25326..8d2305b07 100644 --- a/internal/query/photo.go +++ b/internal/query/photo.go @@ -1,9 +1,10 @@ package query import ( + "time" + "github.com/dustin/go-humanize/english" "github.com/jinzhu/gorm" - "time" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/mutex" diff --git a/internal/server/routes_static.go b/internal/server/routes_static.go index 678167a98..132044d61 100644 --- a/internal/server/routes_static.go +++ b/internal/server/routes_static.go @@ -4,18 +4,17 @@ import ( "net/http" "path/filepath" - "github.com/photoprism/photoprism/internal/i18n" - "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/i18n" ) // registerStaticRoutes configures serving static assets and templates. func registerStaticRoutes(router *gin.Engine, conf *config.Config) { // Redirects to the PWA for now, can be replaced by a template later. router.GET(conf.BaseUri("/"), func(c *gin.Context) { - c.Redirect(http.StatusTemporaryRedirect, conf.BaseUri("/library/login")) + c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri()) }) // Shows "Page Not found" error if no other handler is registered.