Auth: Add dummy LDAP service #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-11-22 22:14:34 +01:00
parent d8712b4636
commit cc38922cbe
36 changed files with 587 additions and 337 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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})"
></v-text-field>
</v-flex>
@ -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;

View file

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

View file

@ -45,13 +45,19 @@
></v-text-field>
<v-spacer></v-spacer>
<div class="action-buttons text-xs-center">
<!-- a href="#" target="_blank" class="text-link px-2" :style="`color: ${colors.link}!important`"><translate>Forgot password?</translate></a -->
<v-btn :color="colors.primary" depressed :disabled="loginDisabled"
class="white--text action-confirm ra-6 px-3" @click.stop="login">
<v-btn v-if="registerUri" :color="colors.secondary" outline :block="$vuetify.breakpoint.xsOnly"
:style="`color: ${colors.link}!important`" class="action-register ra-6 px-3 py-2 opacity-80" @click.stop="register">
<translate>Create Account</translate>
</v-btn>
<v-btn :color="colors.primary" depressed :disabled="loginDisabled" :block="$vuetify.breakpoint.xsOnly"
class="white--text action-confirm ra-6 py-2 px-3" @click.stop="login">
<translate>Sign in</translate>
<v-icon :right="!rtl" :left="rtl" dark>arrow_forward</v-icon>
</v-btn>
</div>
<div v-if="passwordResetUri" class="text-xs-center opacity-80">
<a :href="passwordResetUri" class="text-link" :style="`color: ${colors.link}!important`"><translate>Forgot Password?</translate></a>
</div>
</v-card-text>
</v-card>
</v-form>
@ -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();

View file

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

4
go.mod
View file

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

8
go.sum
View file

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

View file

@ -34,6 +34,7 @@ func CreateSession(router *gin.RouterGroup) {
data := gin.H{
"status": "ok",
"id": sess.ID,
"provider": sess.AuthProvider,
"user": sess.User(),
"data": sess.Data(),
"config": conf.ClientPublic(),
@ -88,6 +89,7 @@ func CreateSession(router *gin.RouterGroup) {
data := gin.H{
"status": "ok",
"id": sess.ID,
"provider": sess.AuthProvider,
"user": sess.User(),
"data": sess.Data(),
"config": clientConfig,

View file

@ -57,6 +57,7 @@ func GetSession(router *gin.RouterGroup) {
data := gin.H{
"status": "ok",
"id": sess.ID,
"provider": sess.AuthProvider,
"user": sess.User(),
"data": sess.Data(),
"config": get.Config().ClientSession(sess),

View file

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

View file

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

View file

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

View file

@ -59,6 +59,10 @@ type ClientConfig struct {
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"`
@ -259,6 +263,9 @@ func (c *Config) ClientPublic() ClientConfig {
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{},
@ -336,6 +343,9 @@ func (c *Config) ClientShare() ClientConfig {
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{},
@ -418,6 +428,10 @@ func (c *Config) ClientUser(withSettings bool) ClientConfig {
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{},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,22 +11,26 @@ 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"
)
// 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?
if f.HasCredentials() {
if m.IsRegistered() {
m.RegenerateID()
}
// 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 := FindUserByName(name)
// User found?
if user == nil {
@ -62,12 +64,37 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
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)
}
var user *User
var provider string
// Login credentials provided?
if f.HasCredentials() {
if m.IsRegistered() {
m.RegenerateID()
}
user, provider, err = Auth(f, m, c)
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() {

View file

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

View file

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

View file

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

View file

@ -52,6 +52,12 @@ const (
IsUnstacked int8 = -1
)
// Authentication providers.
const (
ProviderNone = ""
ProviderPassword = "password"
)
// Sort options.
const (
SortOrderDefault = ""

View file

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

View file

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