OAuth2: Add Client Credentials Authentication #213 #782 #808 #3730 #3943

This adds standard OAuth2 client credentials and bearer token support as
well as scope-based authorization checks for REST API clients. Note that
this initial implementation should not be used in production and that
the access token limit has not been implemented yet.

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2023-12-12 18:42:50 +01:00
parent e80b07795c
commit 467f7b1585
78 changed files with 2474 additions and 406 deletions

14
go.mod
View File

@@ -57,7 +57,7 @@ require (
require github.com/olekukonko/tablewriter v0.0.5
require github.com/google/uuid v1.4.0
require github.com/google/uuid v1.5.0
require (
github.com/chzyer/readline v1.5.1 // indirect
@@ -73,6 +73,11 @@ require (
require github.com/go-ldap/ldap/v3 v3.4.6
require (
github.com/prometheus/client_golang v1.17.0
github.com/prometheus/common v0.45.0
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -97,16 +102,13 @@ require (
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mandykoh/go-parallel v0.1.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.17.0 // indirect
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -124,7 +126,7 @@ require (
github.com/abema/go-mp4 v1.1.1
github.com/bytedance/sonic v1.10.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-playground/validator/v10 v10.15.3 // indirect
github.com/go-playground/validator/v10 v10.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/sunfish-shogi/bufseekio v0.1.0
golang.org/x/arch v0.5.0 // indirect

108
go.sum
View File

@@ -611,6 +611,14 @@ github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE=
github.com/alecthomas/kingpin/v2 v2.3.2/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
@@ -621,6 +629,8 @@ github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4x
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -633,8 +643,10 @@ github.com/bytedance/sonic v1.10.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf5
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
@@ -771,11 +783,20 @@ github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmn
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
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-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM=
github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A=
github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc=
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-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -789,10 +810,11 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.15.3 h1:S+sSpunYjNPDuXkWbK+x+bA7iXiW296KG4dL3X7xUZo=
github.com/go-playground/validator/v10 v10.15.3/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U=
github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
@@ -801,6 +823,7 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
@@ -898,8 +921,8 @@ github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkj
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
@@ -952,10 +975,16 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
@@ -971,11 +1000,15 @@ github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8t
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
@@ -1017,17 +1050,24 @@ github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw=
github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
@@ -1048,21 +1088,46 @@ github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -1073,8 +1138,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
@@ -1087,6 +1152,9 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs=
github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -1095,6 +1163,7 @@ github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY52
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -1134,6 +1203,8 @@ github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 h1:TtyC78WMafNW8Q
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6/go.mod h1:h8272+G2omSmi30fBXiZDMkmHuOgonplfKIKjQWzlfs=
github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk=
github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA=
github.com/xhit/go-str2duration v1.2.0/go.mod h1:3cPSlfZlUHVlneIVfePFWcJZsuwf+P1v2SRTV4cUmp4=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -1165,6 +1236,7 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -1256,6 +1328,7 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -1263,6 +1336,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -1291,6 +1365,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -1316,6 +1391,7 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
@@ -1350,6 +1426,7 @@ golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1369,13 +1446,17 @@ golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1385,6 +1466,7 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1398,6 +1480,8 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1406,6 +1490,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1417,6 +1502,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1430,6 +1516,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220207234003-57398862261d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1458,6 +1545,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
@@ -1859,8 +1948,10 @@ google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -1871,8 +1962,11 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -1,11 +1,51 @@
package acl
// Predefined grants to simplify configuration.
// Standard grants provided to simplify configuration.
var (
GrantFullAccess = Grant{FullAccess: true, AccessAll: true, AccessLibrary: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionDownload: true, ActionShare: true, ActionRate: true, ActionReact: true, ActionManage: true, ActionSubscribe: true}
GrantSearchShared = Grant{AccessShared: true, ActionSearch: true, ActionView: true, ActionDownload: true}
GrantSubscribeAll = Grant{AccessAll: true, ActionSubscribe: true}
GrantSubscribeOwn = Grant{AccessOwn: true, ActionSubscribe: true}
GrantFullAccess = Grant{
FullAccess: true,
AccessAll: true,
AccessOwn: true,
AccessShared: true,
AccessLibrary: true,
ActionCreate: true,
ActionUpdate: true,
ActionDelete: true,
ActionDownload: true,
ActionShare: true,
ActionRate: true,
ActionReact: true,
ActionManage: true,
ActionSubscribe: true,
}
GrantSubscribeAll = Grant{
AccessAll: true,
ActionSubscribe: true,
}
GrantSubscribeOwn = Grant{
AccessOwn: true,
ActionSubscribe: true,
}
GrantViewAll = Grant{
AccessAll: true,
ActionView: true,
}
GrantViewOwn = Grant{
AccessOwn: true,
ActionView: true,
}
GrantViewShared = Grant{
AccessShared: true,
ActionView: true,
ActionDownload: true,
}
GrantSearchShared = Grant{
AccessShared: true,
ActionSearch: true,
ActionView: true,
ActionDownload: true,
}
GrantNone = Grant{}
)
// Grant represents permissions granted or denied.
@@ -21,3 +61,10 @@ func (grant Grant) Allow(perm Permission) bool {
return false
}
// GrantDefaults defines default grants for all supported roles.
var GrantDefaults = Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantViewShared,
RoleClient: GrantFullAccess,
}

View File

@@ -4,26 +4,28 @@ import "strings"
// Resources that Roles can be granted Permission.
const (
ResourceDefault Resource = "default"
ResourcePhotos Resource = "photos"
ResourceFavorites Resource = "favorites"
ResourceAlbums Resource = "albums"
ResourcePeople Resource = "people"
ResourceMoments Resource = "moments"
ResourceCalendar Resource = "calendar"
ResourcePlaces Resource = "places"
ResourceLabels Resource = "labels"
ResourceLogs Resource = "logs"
ResourceConfig Resource = "config"
ResourceSettings Resource = "settings"
ResourcePassword Resource = "password"
ResourceUsers Resource = "users"
ResourceServices Resource = "services"
ResourceFiles Resource = "files"
ResourceFolders Resource = "folders"
ResourceShares Resource = "shares"
ResourcePhotos Resource = "photos"
ResourceVideos Resource = "videos"
ResourceFavorites Resource = "favorites"
ResourceAlbums Resource = "albums"
ResourceMoments Resource = "moments"
ResourceCalendar Resource = "calendar"
ResourcePeople Resource = "people"
ResourcePlaces Resource = "places"
ResourceLabels Resource = "labels"
ResourceConfig Resource = "config"
ResourceSettings Resource = "settings"
ResourcePassword Resource = "password"
ResourceServices Resource = "services"
ResourceUsers Resource = "users"
ResourceLogs Resource = "logs"
ResourceWebDAV Resource = "webdav"
ResourceMetrics Resource = "metrics"
ResourceFeedback Resource = "feedback"
ResourceDefault Resource = "default"
)
// Resource represents a resource for which roles can be granted Permission.

View File

@@ -3,71 +3,82 @@ package acl
// Resources specifies granted permissions by Resource and Role.
var Resources = ACL{
ResourceFiles: Roles{
RoleAdmin: GrantFullAccess,
},
ResourcePhotos: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
ResourceVideos: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
ResourceAlbums: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantSearchShared,
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceFolders: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantSearchShared,
RoleClient: GrantFullAccess,
},
ResourcePlaces: Roles{
ResourceShares: Roles{
RoleAdmin: GrantFullAccess,
},
ResourcePhotos: GrantDefaults,
ResourceVideos: GrantDefaults,
ResourceFavorites: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceAlbums: GrantDefaults,
ResourceMoments: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
RoleVisitor: GrantSearchShared,
RoleClient: GrantFullAccess,
},
ResourceCalendar: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantSearchShared,
},
ResourceMoments: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantSearchShared,
RoleClient: GrantFullAccess,
},
ResourcePeople: Roles{
RoleAdmin: GrantFullAccess,
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceFavorites: Roles{
RoleAdmin: GrantFullAccess,
ResourcePlaces: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
RoleClient: GrantFullAccess,
},
ResourceLabels: Roles{
RoleAdmin: GrantFullAccess,
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceLogs: Roles{
RoleAdmin: GrantFullAccess,
ResourceConfig: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantViewOwn,
RoleDefault: GrantViewOwn,
},
ResourceSettings: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessOwn: true, ActionView: true},
},
ResourceFeedback: Roles{
ResourceServices: Roles{
RoleAdmin: GrantFullAccess,
},
ResourcePassword: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceShares: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceServices: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceUsers: Roles{
RoleAdmin: Grant{AccessAll: true, AccessOwn: true, ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionSubscribe: true},
},
ResourceConfig: Roles{
ResourceLogs: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceWebDAV: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceMetrics: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantViewAll,
},
ResourceFeedback: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceDefault: Roles{
RoleAdmin: GrantFullAccess,
RoleAdmin: GrantFullAccess,
RoleClient: GrantNone,
},
}

View File

@@ -5,6 +5,7 @@ const (
RoleDefault Role = "default"
RoleAdmin Role = "admin"
RoleVisitor Role = "visitor"
RoleClient Role = "client"
RoleUnknown Role = ""
)

View File

@@ -2,9 +2,11 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
)
// Auth checks if the user has permission to access the specified resource and returns the session if so.
@@ -14,27 +16,63 @@ func Auth(c *gin.Context, resource acl.Resource, grant acl.Permission) *entity.S
// AuthAny checks if at least one permission allows access and returns the session in this case.
func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s *entity.Session) {
// Get client IP address and session ID, if any.
// Get the client IP and session ID from the request headers.
ip := ClientIP(c)
sessId := SessionID(c)
sid := SessionID(c)
// Find client session.
if s = Session(sessId); s == nil {
event.AuditWarn([]string{ip, "unauthenticated", "%s %s as unknown user", "denied"}, grants.String(), string(resource))
// Find active session to perform authorization check or deny if no session was found.
if s = Session(sid); s == nil {
event.AuditWarn([]string{ip, "unauthenticated", "%s %s", "denied"}, grants.String(), string(resource))
return entity.SessionStatusUnauthorized()
} else {
s.SetClientIP(ip)
}
// Check authorization.
if s.User() == nil {
event.AuditWarn([]string{ip, "session %s", "%s %s as unknown user", "denied"}, s.RefID, grants.String(), string(resource))
// If the request is from a client application, check its authorization based
// on the allowed scope, the ACL, and the user account it belongs to (if any).
if s.Provider() == authn.ProviderClient {
// Check ACL resource name against the permitted scope.
if !s.HasScope(resource.String()) {
event.AuditErr([]string{ip, "client %s", "session %s", "access %s", "denied"}, s.AuthID, s.RefID, string(resource))
return s
}
// Perform an authorization check based on the ACL defaults for client applications.
if acl.Resources.DenyAll(resource, acl.RoleClient, grants) {
event.AuditErr([]string{ip, "client %s", "session %s", "%s %s", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource))
return entity.SessionStatusForbidden()
}
// Additionally check the user authorization if the client belongs to a user account.
if s.NoUser() {
// Allow access based on the ACL defaults for client applications.
event.AuditInfo([]string{ip, "client %s", "session %s", "%s %s", "granted"}, s.AuthID, s.RefID, grants.String(), string(resource))
} else if u := s.User(); !u.IsDisabled() && !u.IsUnknown() && u.IsRegistered() {
if acl.Resources.DenyAll(resource, u.AclRole(), grants) {
event.AuditErr([]string{ip, "client %s", "session %s", "%s %s as %s", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource), u.String())
return entity.SessionStatusForbidden()
}
// Allow access based on the user role.
event.AuditInfo([]string{ip, "client %s", "session %s", "%s %s as %s", "granted"}, s.AuthID, s.RefID, grants.String(), string(resource), u.String())
} else {
// Deny access if it is not a regular user account or the account has been disabled.
event.AuditErr([]string{ip, "client %s", "session %s", "%s %s as unauthorized user", "denied"}, s.AuthID, s.RefID, grants.String(), string(resource))
return entity.SessionStatusForbidden()
}
return s
}
// Otherwise, perform a regular ACL authorization check based on the user role.
if u := s.User(); u.IsUnknown() || u.IsDisabled() {
event.AuditWarn([]string{ip, "session %s", "%s %s as unauthorized user", "denied"}, s.RefID, grants.String(), string(resource))
return entity.SessionStatusUnauthorized()
} else if acl.Resources.DenyAll(resource, s.User().AclRole(), grants) {
event.AuditErr([]string{ip, "session %s", "%s %s as %s", "denied"}, s.RefID, grants.String(), string(resource), s.User().AclRole().String())
} else if acl.Resources.DenyAll(resource, u.AclRole(), grants) {
event.AuditErr([]string{ip, "session %s", "%s %s as %s", "denied"}, s.RefID, grants.String(), string(resource), u.AclRole().String())
return entity.SessionStatusForbidden()
} else {
event.AuditInfo([]string{ip, "session %s", "%s %s as %s", "granted"}, s.RefID, grants.String(), string(resource), s.User().AclRole().String())
event.AuditInfo([]string{ip, "session %s", "%s %s as %s", "granted"}, s.RefID, grants.String(), string(resource), u.AclRole().String())
return s
}
}

View File

@@ -0,0 +1,81 @@
package api
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/clean"
)
// SessionID returns the session ID from the request context,
// or an empty string if there is none.
func SessionID(c *gin.Context) string {
// Default is an empty string if no context or ID is set.
if c == nil {
return ""
}
// First check the X-Session-ID header for an existing ID.
if id := clean.ID(c.GetHeader(header.SessionID)); id != "" {
return id
}
// Otherwise, return the bearer token, if any.
return BearerToken(c)
}
// BearerToken returns the value of the bearer token header, or an empty string if there is none.
func BearerToken(c *gin.Context) string {
if authType, bearerToken := Authorization(c); authType == "Bearer" && bearerToken != "" {
return bearerToken
}
return ""
}
// Authorization returns the authentication type and token from the authorization request header,
// or an empty string if there is none.
func Authorization(c *gin.Context) (authType, authToken string) {
if s := c.GetHeader(header.Authorization); s == "" {
// Ignore.
} else if t := strings.Split(s, " "); len(t) != 2 {
// Ignore.
} else {
return clean.ID(t[0]), clean.ID(t[1])
}
return "", ""
}
// BasicAuth checks the basic authorization header for credentials and returns them if found.
//
// Note that OAuth 2.0 defines basic authentication differently than RFC 7617, however, this
// does not matter as long as only alphanumeric characters are used for client id and secret:
// https://www.scottbrady91.com/oauth/client-authentication#:~:text=OAuth%20Basic%20Authentication
func BasicAuth(c *gin.Context) (username, password, cacheKey string) {
authType, authToken := Authorization(c)
if authType != "Basic" || authToken == "" {
return "", "", ""
}
auth, err := base64.StdEncoding.DecodeString(authToken)
if err != nil {
return "", "", ""
}
credentials := strings.SplitN(string(auth), ":", 2)
if len(credentials) != 2 {
return "", "", ""
}
cacheKey = fmt.Sprintf("%x", sha1.Sum([]byte(authToken)))
return credentials[0], credentials[1], cacheKey
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/internal/server/header"
)
// AuthenticateAdmin Register session routes and returns valid SessionId.
@@ -27,7 +27,7 @@ func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, pas
Password: password,
}))
sessId = r.Header().Get(session.Header)
sessId = r.Header().Get(header.SessionID)
return
}
@@ -37,7 +37,7 @@ func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.R
req, _ := http.NewRequest(method, path, nil)
if sess != "" {
req.Header.Add(session.Header, sess)
req.Header.Add(header.SessionID, sess)
}
w := httptest.NewRecorder()
@@ -52,7 +52,7 @@ func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, ses
req, _ := http.NewRequest(method, path, reader)
if sess != "" {
req.Header.Add(session.Header, sess)
req.Header.Add(header.SessionID, sess)
}
w := httptest.NewRecorder()

View File

@@ -8,7 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/internal/server/header"
)
// AddCountHeader adds the actual result count to the response.
@@ -33,7 +33,7 @@ func AddDownloadHeader(c *gin.Context, fileName string) {
// AddSessionHeader adds a session id header to the response.
func AddSessionHeader(c *gin.Context, id string) {
c.Header(session.Header, id)
c.Header(header.SessionID, id)
}
// AddContentTypeHeader adds a content type header to the response.

View File

@@ -5,18 +5,29 @@ import (
"runtime"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/common/expfmt"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/get"
)
// GetMetrics provides a prometheus-compatible metrics endpoint for monitoring.
//
// GET /api/v1/metrics
func GetMetrics(router *gin.RouterGroup) {
router.GET("/metrics", func(c *gin.Context) {
s := Auth(c, acl.ResourceMetrics, acl.AccessAll)
// Abort if permission was not granted.
if s.Abort(c) {
return
}
conf := get.Config()
counts := conf.ClientPublic().Count
@@ -47,7 +58,7 @@ func GetMetrics(router *gin.RouterGroup) {
})
}
// Register metrics that exposes various statistics for this instance.
// registerCountMetrics registers metrics that can be monitored with the /api/v1/metrics endpoint.=
func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts) {
metric := factory.NewGaugeVec(
prometheus.GaugeOpts{
@@ -66,7 +77,7 @@ func registerCountMetrics(factory promauto.Factory, counts config.ClientCounts)
metric.With(prometheus.Labels{"stat": "files"}).Set(float64(counts.Files))
}
// Register a metric that exposes build information for this instance.
// registerBuildInfoMetric registers a metric that provides build information.
func registerBuildInfoMetric(factory promauto.Factory, conf config.ClientConfig) {
factory.NewGaugeVec(
prometheus.GaugeOpts{

View File

@@ -2,8 +2,8 @@ package api
import (
"net/http"
"testing"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)

104
internal/api/oauth.go Normal file
View File

@@ -0,0 +1,104 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
)
// CreateOauthToken creates a new access token and returns it as JSON
// if the client's credentials have been successfully validated.
//
// POST /api/v1/oauth/token
func CreateOauthToken(router *gin.RouterGroup) {
router.POST("/oauth/token", func(c *gin.Context) {
// client_id, client_secret
var err error
var f form.ClientCredentials
// Get client IP address for logs and rate limiting checks.
clientIP := ClientIP(c)
// Allow authentication with basic auth and form values.
if clientId, clientSecret, _ := BasicAuth(c); clientId != "" && clientSecret != "" {
f.ClientID = clientId
f.ClientSecret = clientSecret
} else if err = c.Bind(&f); err != nil {
event.AuditWarn([]string{clientIP, "oauth", "%s"}, err)
AbortBadRequest(c)
return
}
// Check the credentials for completeness and the correct format.
if err = f.Validate(); err != nil {
event.AuditWarn([]string{clientIP, "oauth", "%s"}, err)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
}
// Check limit for failed auth requests (max. 10 per minute).
if limiter.Login.Reject(clientIP) {
limiter.AbortJSON(c)
return
}
// Find the client that has the ID specified in the authentication request.
client := entity.FindClient(f.ClientID)
// Abort if the client ID or secret are invalid.
if client == nil {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client id"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
limiter.Login.Reserve(clientIP)
return
} else if !client.AuthEnabled {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "authentication disabled"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if client.AuthMethod != authn.MethodOAuth2.String() {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "%s authentication not supported"}, f.ClientID, client.AuthMethod)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if client.WrongSecret(f.ClientSecret) {
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client secret"}, f.ClientID)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
limiter.Login.Reserve(clientIP)
return
}
// Create new client session.
sess := client.NewSession(c)
// TODO: Enforce limit for maximum number of access tokens.
// Try to log in and save session if successful.
if sess, err = get.Session().Save(sess); err != nil {
event.AuditErr([]string{clientIP, "client %s", "create access token", "%s"}, f.ClientID, err)
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if sess == nil {
event.AuditErr([]string{clientIP, "client %s", "create access token", "failed unexpectedly"}, f.ClientID)
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
return
} else {
event.AuditInfo([]string{clientIP, "client %s", "session %s", "access token created"}, f.ClientID, sess.RefID)
}
// Return access token.
data := gin.H{
"access_token": sess.ID,
"token_type": "Bearer",
"expires_in": sess.ExpiresIn(),
}
c.JSON(http.StatusOK, data)
})
}

View File

@@ -0,0 +1,38 @@
package api
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCreateOauthToken(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateOauthToken(router)
var method = "POST"
var path = "/api/v1/oauth/token"
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {"cs5cpu17n6gj2qo5"},
"client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"},
"scope": {"metrics"},
}
req, _ := http.NewRequest(method, path, strings.NewReader(data.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
t.Logf("Header: %s", w.Header())
t.Logf("BODY: %s", w.Body.String())
assert.Equal(t, http.StatusOK, w.Code)
})
}

View File

@@ -1,25 +1,10 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/clean"
)
// SessionID returns the session ID from the request context.
func SessionID(c *gin.Context) (sessId string) {
if c == nil {
// Should never happen.
return ""
}
// Get the authentication token from the HTTP headers.
return clean.ID(c.GetHeader(session.Header))
}
// Session finds the client session for the given ID or returns nil otherwise.
func Session(id string) *entity.Session {
// Skip authentication if app is running in public mode.

View File

@@ -78,7 +78,7 @@ func ZipCreate(router *gin.RouterGroup) {
// Configure file names.
dlName := DownloadName(c)
zipPath := path.Join(conf.TempPath(), "zip")
zipToken := rnd.GenerateToken(8)
zipToken := rnd.Base36(8)
zipBaseName := fmt.Sprintf("photoprism-download-%s-%s.zip", time.Now().Format("20060102-150405"), zipToken)
zipFileName := path.Join(zipPath, zipBaseName)

View File

@@ -0,0 +1,99 @@
package commands
import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/urfave/cli"
)
// Usage hints for the client management subcommands.
const (
ClientNameUsage = "arbitrary name to help identify the `CLIENT` application"
ClientUserName = "a `USERNAME` is only required if the client belongs to a specific account"
ClientAuthMethod = "supported authentication `METHOD` for the client application"
ClientAuthScope = "authorization `SCOPE` of the client e.g. \"metrics\" (\"*\" to allow all scopes)"
ClientAuthExpires = "access token expiration time in `SECONDS`, after which a new token must be created"
ClientAuthTokens = "maximum `NUMBER` of access tokens the client can create (-1 to disable the limit)"
ClientRegenerateSecret = "generate a new client secret and display it"
ClientDisable = "deactivate authentication with this client"
ClientEnable = "re-enable client authentication"
)
// ClientsCommand configures the client application subcommands.
var ClientsCommand = cli.Command{
Name: "clients",
Usage: "API authentication subcommands",
Subcommands: []cli.Command{
ClientsListCommand,
ClientsAddCommand,
ClientsShowCommand,
ClientsModCommand,
ClientsRemoveCommand,
ClientsResetCommand,
},
}
// ClientAddFlags specifies the "photoprism client add" command flags.
var ClientAddFlags = []cli.Flag{
cli.StringFlag{
Name: "name, n",
Usage: ClientNameUsage,
},
cli.StringFlag{
Name: "user, u",
Usage: ClientUserName,
},
cli.StringFlag{
Name: "method, m",
Usage: ClientAuthMethod,
Value: authn.MethodOAuth2.String(),
},
cli.StringFlag{
Name: "scope, s",
Usage: ClientAuthScope,
},
cli.Int64Flag{
Name: "expires, e",
Usage: ClientAuthExpires,
},
cli.Int64Flag{
Name: "tokens, t",
Usage: ClientAuthTokens,
},
}
// ClientModFlags specifies the "photoprism client mod" command flags.
var ClientModFlags = []cli.Flag{
cli.StringFlag{
Name: "name, n",
Usage: ClientNameUsage,
},
cli.StringFlag{
Name: "method, m",
Usage: ClientAuthMethod,
Value: authn.MethodOAuth2.String(),
},
cli.StringFlag{
Name: "scope, s",
Usage: ClientAuthScope,
},
cli.Int64Flag{
Name: "expires, e",
Usage: ClientAuthExpires,
},
cli.Int64Flag{
Name: "tokens, t",
Usage: ClientAuthTokens,
},
cli.BoolFlag{
Name: "regenerate-secret, r",
Usage: ClientRegenerateSecret,
},
cli.BoolFlag{
Name: "disable",
Usage: ClientDisable,
},
cli.BoolFlag{
Name: "enable",
Usage: ClientEnable,
},
}

View File

@@ -0,0 +1,138 @@
package commands
import (
"fmt"
"time"
"github.com/manifoldco/promptui"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/report"
)
// ClientsAddCommand configures the command name, flags, and action.
var ClientsAddCommand = cli.Command{
Name: "add",
Usage: "Registers a new client application",
Flags: ClientAddFlags,
Action: clientsAddAction,
}
// clientsAddAction registers a new client application.
func clientsAddAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil)
frm := form.NewClientFromCli(ctx)
interactive := true
if frm.ClientName != "" && frm.AuthScope != "" {
log.Debugf("client will be added in non-interactive mode")
interactive = false
}
if interactive && frm.ClientName == "" {
prompt := promptui.Prompt{
Label: "Client Name",
}
res, err := prompt.Run()
if err != nil {
return err
}
frm.ClientName = clean.Name(res)
}
// Set a default client name if no specific name has been provided.
if frm.ClientName == "" {
frm.ClientName = time.Now().UTC().Format(time.DateTime)
}
if interactive && frm.AuthScope == "" {
prompt := promptui.Prompt{
Label: "Authorization Scope",
}
res, err := prompt.Run()
if err != nil {
return err
}
frm.AuthScope = clean.Scope(res)
}
// Set a default client name if no specific name has been provided.
if frm.AuthScope == "" {
frm.AuthScope = list.All
}
client, addErr := entity.AddClient(frm)
if addErr != nil {
return fmt.Errorf("failed to add client: %s", addErr)
} else {
log.Infof("successfully registered new client %s", clean.LogQuote(client.ClientName))
// Display client details.
cols := []string{"Client ID", "Client Name", "Authentication", "Scope", "User", "Enabled", "Access Token Expires", "Created At"}
rows := make([][]string, 1)
var userName string
if client.UserUID == "" {
userName = report.NotAssigned
} else if client.UserName != "" {
userName = client.UserName
} else {
userName = client.UserUID
}
var authExpires string
if client.AuthExpires > 0 {
authExpires = client.Expires().String()
} else {
authExpires = report.Never
}
if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s, max %d tokens", authExpires, client.AuthTokens)
}
rows[0] = []string{
client.UID(),
client.ClientName,
client.AuthMethod,
client.AuthScope,
userName,
report.Bool(client.AuthEnabled, report.Yes, report.No),
authExpires,
client.CreatedAt.Format("2006-01-02 15:04:05"),
}
if result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)); err == nil {
fmt.Printf("\n%s", result)
}
}
if secret, err := client.NewSecret(); err != nil {
// Failed to create client secret.
return fmt.Errorf("failed to create client secret: %s", err)
} else {
// Show client authentication credentials.
fmt.Printf("\nTHE FOLLOWING RANDOMLY GENERATED CLIENT ID AND SECRET ARE REQUIRED FOR AUTHENTICATION:\n")
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s", result)
fmt.Printf("\nPLEASE WRITE THE CREDENTIALS DOWN AND KEEP THEM IN A SAFE PLACE, AS THE SECRET CANNOT BE DISPLAYED AGAIN.\n\n")
}
return nil
})
}

View File

@@ -0,0 +1,85 @@
package commands
import (
"fmt"
"github.com/dustin/go-humanize/english"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/report"
)
// ClientsListCommand configures the command name, flags, and action.
var ClientsListCommand = cli.Command{
Name: "ls",
Usage: "Lists registered client applications",
ArgsUsage: "[search]",
Flags: append(report.CliFlags, countFlag),
Action: clientsListAction,
}
// clientsListAction lists registered client applications
func clientsListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"Client ID", "Client Name", "Authentication", "Scope", "User", "Enabled", "Access Token Expires", "Created At"}
// Fetch clients from database.
clients, err := query.Clients(ctx.Int("n"), 0, "", ctx.Args().First())
if err != nil {
return err
}
rows := make([][]string, len(clients))
if len(clients) == 0 {
log.Warnf("no clients registered")
return nil
}
// Show log message.
log.Infof("found %s", english.Plural(len(clients), "client", "clients"))
// Display report.
for i, client := range clients {
var userName string
if client.UserUID == "" {
userName = report.NotAssigned
} else if client.UserName != "" {
userName = client.UserName
} else {
userName = client.UserUID
}
var authExpires string
if client.AuthExpires > 0 {
authExpires = client.Expires().String()
} else {
authExpires = report.Never
}
if client.AuthTokens > 0 {
authExpires = fmt.Sprintf("%s, max %d tokens", authExpires, client.AuthTokens)
}
rows[i] = []string{
client.UID(),
client.ClientName,
client.AuthMethod,
client.AuthScope,
userName,
report.Bool(client.AuthEnabled, report.Yes, report.No),
authExpires,
client.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", result)
return err
})
}

View File

@@ -0,0 +1,111 @@
package commands
import (
"fmt"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report"
)
// ClientsModCommand configures the command name, flags, and action.
var ClientsModCommand = cli.Command{
Name: "mod",
Usage: "Updates client application settings",
ArgsUsage: "[id]",
Flags: ClientModFlags,
Action: clientsModAction,
}
// clientsModAction updates client application settings.
func clientsModAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil)
id := clean.UID(ctx.Args().First())
// Name or UID provided?
if id == "" {
log.Infof("no valid client id specified")
return cli.ShowSubcommandHelp(ctx)
}
// Find client record.
var client *entity.Client
client = entity.FindClient(id)
if client == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id))
}
if name := clean.Name(ctx.String("name")); name != "" {
client.ClientName = name
}
if ctx.IsSet("method") {
client.AuthMethod = authn.Method(ctx.String("method")).String()
}
if ctx.IsSet("scope") {
client.SetScope(ctx.String("scope"))
}
if expires := ctx.Int64("expires"); expires != 0 {
if expires > entity.UnixMonth {
client.AuthExpires = entity.UnixMonth
} else if expires > 0 {
client.AuthExpires = expires
} else if expires <= 0 {
client.AuthExpires = entity.UnixHour
}
}
if tokens := ctx.Int64("tokens"); tokens != 0 {
if tokens > 2147483647 {
client.AuthTokens = 2147483647
} else if tokens > 0 {
client.AuthTokens = tokens
} else if tokens < 0 {
client.AuthTokens = -1
}
}
if ctx.IsSet("disable") && ctx.Bool("disable") {
client.AuthEnabled = false
log.Infof("disabled client authentication")
} else if ctx.IsSet("enable") && ctx.Bool("enable") {
client.AuthEnabled = true
log.Warnf("enabled client authentication")
}
// Save changes.
if err := client.Validate(); err != nil {
return fmt.Errorf("invalid client settings: %s", err)
} else if err = client.Save(); err != nil {
return fmt.Errorf("failed to update client settings: %s", err)
} else {
log.Infof("client %s has been updated", clean.LogQuote(client.ClientName))
}
// Regenerate and display secret, if requested.
if ctx.IsSet("regenerate-secret") && ctx.Bool("regenerate-secret") {
if secret, err := client.NewSecret(); err != nil {
// Failed to create client secret.
return fmt.Errorf("failed to create client secret: %s", err)
} else {
// Show client authentication credentials.
fmt.Printf("\nTHE FOLLOWING RANDOMLY GENERATED CLIENT ID AND SECRET ARE REQUIRED FOR AUTHENTICATION:\n")
result := report.Credentials("Client ID", client.ClientUID, "Client Secret", secret)
fmt.Printf("\n%s", result)
fmt.Printf("\nPLEASE WRITE THE CREDENTIALS DOWN AND KEEP THEM IN A SAFE PLACE, AS THE SECRET CANNOT BE DISPLAYED AGAIN.\n\n")
}
}
return nil
})
}

View File

@@ -0,0 +1,72 @@
package commands
import (
"fmt"
"github.com/manifoldco/promptui"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/clean"
)
// ClientsRemoveCommand configures the command name, flags, and action.
var ClientsRemoveCommand = cli.Command{
Name: "rm",
Usage: "Deletes a registered client application",
ArgsUsage: "[id]",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "force, f",
Usage: "don't ask for confirmation",
},
},
Action: clientsRemoveAction,
}
// clientsRemoveAction deletes a registered client application
func clientsRemoveAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil)
id := clean.UID(ctx.Args().First())
// Name or UID provided?
if id == "" {
log.Infof("no valid client id specified")
return cli.ShowSubcommandHelp(ctx)
}
// Find client record.
var m *entity.Client
m = entity.FindClient(id)
if m == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id))
} else if m.Deleted() {
return fmt.Errorf("client %s has already been deleted", clean.LogQuote(id))
}
if !ctx.Bool("force") {
actionPrompt := promptui.Prompt{
Label: fmt.Sprintf("Delete client %s?", m.UID()),
IsConfirm: true,
}
if _, err := actionPrompt.Run(); err != nil {
log.Infof("client %s was not deleted", m.UID())
return nil
}
}
if err := m.Delete(); err != nil {
return err
}
log.Infof("client %s has been deleted", m.UID())
return nil
})
}

View File

@@ -0,0 +1,69 @@
package commands
import (
"fmt"
"github.com/manifoldco/promptui"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
)
// ClientsResetCommand configures the command name, flags, and action.
var ClientsResetCommand = cli.Command{
Name: "reset",
Usage: "Removes all registered client applications",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "trace, t",
Usage: "show trace logs for debugging",
},
cli.BoolFlag{
Name: "yes, y",
Usage: "assume \"yes\" and run non-interactively",
},
},
Action: clientsResetAction,
}
// clientsResetAction removes all registered client applications.
func clientsResetAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
confirmed := ctx.Bool("yes")
// Show prompt?
if !confirmed {
actionPrompt := promptui.Prompt{
Label: fmt.Sprintf("Reset the client database to a clean state?"),
IsConfirm: true,
}
if _, err := actionPrompt.Run(); err != nil {
return nil
}
}
if ctx.Bool("trace") {
log.SetLevel(logrus.TraceLevel)
log.Infoln("reset: enabled trace mode")
}
db := conf.Db()
// Drop existing auth_clients table.
if err := db.DropTableIfExists(entity.Client{}).Error; err != nil {
return err
}
// Re-create auth_clients.
if err := db.CreateTable(entity.Client{}).Error; err != nil {
return err
}
log.Infof("the client database has been recreated and is now in a clean state")
return nil
})
}

View File

@@ -0,0 +1,55 @@
package commands
import (
"fmt"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report"
)
// ClientsShowCommand configures the command name, flags, and action.
var ClientsShowCommand = cli.Command{
Name: "show",
Usage: "Shows client configuration details",
ArgsUsage: "[id]",
Flags: report.CliFlags,
Action: clientsShowAction,
}
// clientsShowAction displays the current client application settings
func clientsShowAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
id := clean.UID(ctx.Args().First())
// Name or UID provided?
if id == "" {
return cli.ShowSubcommandHelp(ctx)
}
// Find client record.
var m *entity.Client
m = entity.FindClient(id)
if m == nil {
return fmt.Errorf("client %s not found", clean.LogQuote(id))
}
// Get client information.
rows, cols := m.Report(true)
// Sort values by name.
report.Sort(rows)
// Show client information.
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", result)
return err
})
}

View File

@@ -63,12 +63,20 @@ var PhotoPrism = []cli.Command{
ResetCommand,
PasswdCommand,
UsersCommand,
ClientsCommand,
ShowCommand,
VersionCommand,
ShowConfigCommand,
ConnectCommand,
}
// countFlag represents a CLI flag to limit the number of report rows.
var countFlag = cli.UintFlag{
Name: "n",
Usage: "`LIMIT` number of results",
Value: 100,
}
// childAlreadyRunning tests if a .pid file at filePath is a running process.
// it returns the pid value and the running status (true or false).
func childAlreadyRunning(filePath string) (pid int, running bool) {

View File

@@ -64,7 +64,7 @@ func passwdAction(ctx *cli.Context) error {
if m == nil {
return fmt.Errorf("user %s not found", clean.LogQuote(id))
} else if m.Deleted() {
} else if m.IsDeleted() {
return fmt.Errorf("user %s has been deleted", clean.LogQuote(id))
}

View File

@@ -17,7 +17,7 @@ import (
// UsersAddCommand configures the command name, flags, and action.
var UsersAddCommand = cli.Command{
Name: "add",
Usage: "Adds a new user account",
Usage: "Creates a new user account",
ArgsUsage: "[username]",
Flags: UserFlags,
Action: usersAddAction,
@@ -55,7 +55,7 @@ func usersAddAction(ctx *cli.Context) error {
if frm.UserName == "" {
return fmt.Errorf("username is required")
} else if m := entity.FindUserByName(frm.UserName); m != nil {
if !m.Deleted() {
if !m.IsDeleted() {
return fmt.Errorf("user already exists")
}

View File

@@ -14,7 +14,7 @@ import (
// UsersLegacyCommand configures the command name, flags, and action.
var UsersLegacyCommand = cli.Command{
Name: "legacy",
Usage: "Displays legacy user accounts",
Usage: "Lists legacy user accounts",
ArgsUsage: "[search]",
Flags: report.CliFlags,
Action: usersLegacyAction,

View File

@@ -15,7 +15,7 @@ import (
// UsersListCommand configures the command name, flags, and action.
var UsersListCommand = cli.Command{
Name: "ls",
Usage: "Displays existing user accounts",
Usage: "Lists registered user accounts",
Flags: report.CliFlags,
Action: usersListAction,
}

View File

@@ -15,7 +15,7 @@ import (
// UsersModCommand configures the command name, flags, and action.
var UsersModCommand = cli.Command{
Name: "mod",
Usage: "Modifies an existing user account",
Usage: "Changes user account settings",
ArgsUsage: "[username]",
Flags: UserFlags,
Action: usersModAction,
@@ -47,7 +47,7 @@ func usersModAction(ctx *cli.Context) error {
}
// Check if account exists but is deleted.
if m.Deleted() {
if m.IsDeleted() {
prompt := promptui.Prompt{
Label: fmt.Sprintf("Restore user %s?", m.String()),
IsConfirm: true,

View File

@@ -15,7 +15,7 @@ import (
// UsersRemoveCommand configures the command name, flags, and action.
var UsersRemoveCommand = cli.Command{
Name: "rm",
Usage: "Removes a user account",
Usage: "Deletes a registered user account",
ArgsUsage: "[username]",
Flags: []cli.Flag{
cli.BoolFlag{
@@ -49,7 +49,7 @@ func usersRemoveAction(ctx *cli.Context) error {
if m == nil {
return fmt.Errorf("user %s not found", clean.LogQuote(id))
} else if m.Deleted() {
} else if m.IsDeleted() {
return fmt.Errorf("user %s has already been deleted", clean.LogQuote(id))
}

View File

@@ -14,7 +14,7 @@ import (
// UsersResetCommand configures the command name, flags, and action.
var UsersResetCommand = cli.Command{
Name: "reset",
Usage: "Resets the user database to a clean state",
Usage: "Removes all registered user accounts",
Flags: []cli.Flag{
cli.BoolFlag{
Name: "trace, t",

View File

@@ -3,20 +3,19 @@ package commands
import (
"fmt"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/report"
"github.com/photoprism/photoprism/pkg/rnd"
)
// UsersShowCommand configures the command name, flags, and action.
var UsersShowCommand = cli.Command{
Name: "show",
Usage: "Shows user account information",
Usage: "Shows detailed account information",
ArgsUsage: "[username]",
Flags: report.CliFlags,
Action: usersShowAction,

View File

@@ -113,7 +113,7 @@ func NewConfig(ctx *cli.Context) *Config {
c := &Config{
cliCtx: ctx,
options: NewOptions(ctx),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
env: os.Getenv("DOCKER_ENV"),
start: start,
}

View File

@@ -165,7 +165,7 @@ func (c *Config) DownloadToken() string {
if c.Public() {
return entity.TokenPublic
} else if c.options.DownloadToken == "" {
c.options.DownloadToken = rnd.GenerateToken(8)
c.options.DownloadToken = rnd.Base36(8)
}
return c.options.DownloadToken

View File

@@ -191,7 +191,7 @@ func TestConfig_CreateDirectories(t *testing.T) {
c := &Config{
options: NewTestOptions("config"),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
if err := c.CreateDirectories(); err != nil {
@@ -211,7 +211,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.AssetsPath = ""
@@ -235,7 +235,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.StoragePath = "/-*&^%$#@!`~"
@@ -252,7 +252,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.OriginalsPath = ""
@@ -277,7 +277,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.ImportPath = ""
@@ -302,7 +302,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.SidecarPath = "/-*&^%$#@!`~"
@@ -319,7 +319,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.CachePath = "/-*&^%$#@!`~"
@@ -336,7 +336,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.ConfigPath = "/-*&^%$#@!`~"
@@ -353,7 +353,7 @@ func TestConfig_CreateDirectories2(t *testing.T) {
defer testConfigMutex.Unlock()
c := &Config{
options: NewTestOptions(),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
c.options.TempPath = "/-*&^%$#@!`~"

View File

@@ -160,7 +160,7 @@ func NewTestConfig(pkg string) *Config {
c := &Config{
cliCtx: CliTestContext(),
options: NewTestOptions(pkg),
token: rnd.GenerateToken(8),
token: rnd.Base36(8),
}
s := customize.NewSettings(c.DefaultTheme(), c.DefaultLocale())

View File

@@ -0,0 +1,370 @@
package entity
import (
"errors"
"fmt"
"time"
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// ClientUID is the unique ID prefix.
const (
ClientUID = byte('c')
)
// Clients represents a list of client applications.
type Clients []Client
// Client represents a client application.
type Client struct {
ClientUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"ClientUID"`
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
user *User `gorm:"-"`
ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"`
ClientType string `gorm:"type:VARBINARY(16)" json:"ClientType" yaml:"ClientType,omitempty"`
ClientURL string `gorm:"type:VARBINARY(255);default:'';column:client_url;" json:"ClientURL" yaml:"ClientURL,omitempty"`
CallbackURL string `gorm:"type:VARBINARY(255);default:'';column:callback_url;" json:"CallbackURL" yaml:"CallbackURL,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
AuthExpires int64 `json:"AuthExpires" yaml:"AuthExpires,omitempty"`
AuthTokens int64 `json:"AuthTokens" yaml:"AuthTokens,omitempty"` // TODO: Enforce limit for number of tokens.
AuthEnabled bool `json:"AuthEnabled" yaml:"AuthEnabled,omitempty"`
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
}
// TableName returns the entity table name.
func (Client) TableName() string {
return "auth_clients"
}
// NewClient returns a new client application instance.
func NewClient() *Client {
return &Client{
UserUID: "",
ClientName: "",
ClientType: authn.ClientConfidential,
ClientURL: "",
CallbackURL: "",
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "",
AuthExpires: UnixHour,
AuthTokens: 5,
AuthEnabled: true,
LastActive: 0,
}
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Client) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsUID(m.ClientUID, ClientUID) {
return nil
}
m.ClientUID = rnd.GenerateUID(ClientUID)
return scope.SetColumn("ClientUID", m.ClientUID)
}
// FindClient returns the matching client or nil if it was not found.
func FindClient(uid string) *Client {
if rnd.InvalidUID(uid, ClientUID) {
return nil
}
m := &Client{}
// Find matching record.
if err := UnscopedDb().First(m, "client_uid = ?", uid).Error; err != nil {
return nil
}
return m
}
// UID returns the client uid string.
func (m *Client) UID() string {
return m.ClientUID
}
// HasUID tests if the entity has a valid uid.
func (m *Client) HasUID() bool {
return rnd.IsUID(m.ClientUID, ClientUID)
}
// User returns the related user account, if any.
func (m *Client) User() *User {
if m.user != nil {
return m.user
} else if m.UserUID == "" {
return &User{}
}
if u := FindUserByUID(m.UserUID); u != nil {
m.user = u
return m.user
}
return &User{}
}
// SetUser updates the related user account.
func (m *Client) SetUser(u *User) *Client {
if u == nil {
return m
}
// Update user references.
m.user = u
m.UserUID = u.UserUID
m.UserName = u.UserName
return m
}
// Create new entity in the database.
func (m *Client) Create() error {
return Db().Create(m).Error
}
// Save updates the record in the database or inserts a new record if it does not already exist.
func (m *Client) Save() error {
return Db().Save(m).Error
}
// Delete marks the entity as deleted.
func (m *Client) Delete() (err error) {
if m.ClientUID == "" {
return fmt.Errorf("client uid is missing")
}
if err = UnscopedDb().Delete(Session{}, "auth_id = ?", m.ClientUID).Error; err != nil {
event.AuditErr([]string{"client %s", "delete", "failed to remove sessions", "%s"}, m.ClientUID, err)
}
err = Db().Delete(m).Error
FlushSessionCache()
return err
}
// Deleted checks if the client has been deleted.
func (m *Client) Deleted() bool {
if m.DeletedAt == nil {
return false
}
return !m.DeletedAt.IsZero()
}
// Updates multiple properties in the database.
func (m *Client) Updates(values interface{}) error {
return UnscopedDb().Model(m).Updates(values).Error
}
// NewSecret sets a new secret stored as hash.
func (m *Client) NewSecret() (s string, err error) {
if !m.HasUID() {
return "", fmt.Errorf("invalid client uid")
}
s = rnd.Base62(32)
pw := NewPassword(m.ClientUID, s, false)
if err = pw.Save(); err != nil {
return "", err
}
return s, nil
}
// HasSecret checks if the given client secret is correct.
func (m *Client) HasSecret(s string) bool {
return !m.WrongSecret(s)
}
// WrongSecret checks if the given client secret is incorrect.
func (m *Client) WrongSecret(s string) bool {
if !m.HasUID() {
return true
}
// Empty secret?
if s == "" {
return true
}
// Fetch secret.
pw := FindPassword(m.ClientUID)
// Found?
if pw == nil {
return true
}
// Invalid?
if pw.IsWrong(s) {
return true
}
return false
}
// Method returns the client authentication method.
func (m *Client) Method() authn.MethodType {
return authn.Method(m.AuthMethod)
}
// Scope returns the client authorization scope.
func (m *Client) Scope() string {
return clean.Scope(m.AuthScope)
}
// SetScope sets the client authorization scope.
func (m *Client) SetScope(s string) *Client {
m.AuthScope = clean.Scope(s)
return m
}
// UpdateLastActive sets the last activity of the client to now.
func (m *Client) UpdateLastActive() *Client {
if !m.HasUID() {
return m
}
m.LastActive = UnixTime()
if err := Db().Model(m).UpdateColumn("LastActive", m.LastActive).Error; err != nil {
log.Debugf("client: failed to update %s timestamp (%s)", m.ClientUID, err)
}
return m
}
// NewSession creates a new client session.
func (m *Client) NewSession(c *gin.Context) *Session {
// Update activity timestamp.
m.UpdateLastActive()
// Create, initialize, and return new session.
sess := NewSession(m.AuthExpires, 0).SetContext(c)
sess.AuthID = m.UID()
sess.AuthProvider = authn.ProviderClient.String()
sess.AuthMethod = m.Method().String()
sess.AuthScope = m.Scope()
sess.SetUser(m.User())
return sess
}
// Expires returns the auth expiration duration.
func (m *Client) Expires() time.Duration {
return time.Duration(m.AuthExpires) * time.Second
}
// Report returns the entity values as rows.
func (m *Client) Report(skipEmpty bool) (rows [][]string, cols []string) {
cols = []string{"Name", "Value"}
// Extract model values.
values, _, err := ModelValues(m, "ClientUID")
// Ok?
if err != nil {
return rows, cols
}
rows = make([][]string, 0, len(values))
for k, v := range values {
s := fmt.Sprintf("%#v", v)
// Skip empty values?
if !skipEmpty || s != "" {
rows = append(rows, []string{k, s})
}
}
return rows, cols
}
// SetFormValues sets the values specified in the form.
func (m *Client) SetFormValues(frm form.Client) *Client {
if frm.UserUID == "" && frm.UserName == "" {
// Ignore.
} else if u := FindUser(User{UserUID: frm.UserUID, UserName: frm.UserName}); u != nil {
m.SetUser(u)
}
if frm.ClientName != "" {
m.ClientName = frm.Name()
}
if frm.AuthMethod != "" {
m.AuthMethod = frm.Method().String()
}
if frm.AuthScope != "" {
m.SetScope(frm.AuthScope)
}
if frm.AuthExpires > UnixMonth {
m.AuthExpires = UnixMonth
} else if frm.AuthExpires > 0 {
m.AuthExpires = frm.AuthExpires
} else if m.AuthExpires <= 0 {
m.AuthExpires = UnixHour
}
if frm.AuthTokens > 2147483647 {
m.AuthTokens = 2147483647
} else if frm.AuthTokens > 0 {
m.AuthTokens = frm.AuthTokens
} else if m.AuthTokens < 0 {
m.AuthTokens = -1
}
if frm.AuthEnabled {
m.AuthEnabled = true
}
return m
}
// Validate checks the client application properties before saving them.
func (m *Client) Validate() (err error) {
// Empty client name?
if m.ClientName == "" {
return errors.New("client name must not be empty")
}
// Empty client type?
if m.ClientType == "" {
return errors.New("client type must not be empty")
}
// Empty authorization method?
if m.AuthMethod == "" {
return errors.New("authorization method must not be empty")
}
// Empty authorization scope?
if m.AuthScope == "" {
return errors.New("authorization scope must not be empty")
}
return nil
}

View File

@@ -0,0 +1,18 @@
package entity
import (
"github.com/photoprism/photoprism/internal/form"
)
// AddClient creates a new client and returns it if successful.
func AddClient(frm form.Client) (client *Client, err error) {
client = NewClient().SetFormValues(frm)
if err = client.Validate(); err != nil {
return client, err
} else if err = client.Create(); err != nil {
return client, err
}
return client, nil
}

View File

@@ -0,0 +1,79 @@
package entity
import "github.com/photoprism/photoprism/pkg/authn"
type ClientMap map[string]Client
func (m ClientMap) Get(name string) Client {
if result, ok := m[name]; ok {
return result
}
return Client{}
}
func (m ClientMap) Pointer(name string) *Client {
if result, ok := m[name]; ok {
return &result
}
return &Client{}
}
var ClientFixtures = ClientMap{
"alice": {
ClientUID: "cs5gfen1bgxz7s9i",
UserUID: UserFixtures.Pointer("alice").UserUID,
UserName: UserFixtures.Pointer("alice").UserName,
user: UserFixtures.Pointer("alice"),
ClientName: "Alice",
ClientType: authn.ClientConfidential,
ClientURL: "",
CallbackURL: "",
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "*",
AuthExpires: UnixDay,
AuthTokens: -1,
AuthEnabled: true,
LastActive: 0,
},
"bob": {
ClientUID: "cs5gfsvbd7ejzn8m",
UserUID: UserFixtures.Pointer("bob").UserUID,
UserName: UserFixtures.Pointer("bob").UserName,
user: UserFixtures.Pointer("bob"),
ClientName: "Bob",
ClientType: authn.ClientWebDAV,
ClientURL: "",
CallbackURL: "",
AuthMethod: authn.MethodBasic.String(),
AuthScope: "webdav files photos",
AuthExpires: 0,
AuthTokens: -1,
AuthEnabled: false,
LastActive: 0,
},
"metrics": {
ClientUID: "cs5cpu17n6gj2qo5",
UserUID: "",
UserName: "",
user: nil,
ClientName: "Monitoring",
ClientType: authn.ClientConfidential,
ClientURL: "",
CallbackURL: "",
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "metrics",
AuthExpires: UnixHour,
AuthTokens: 2,
AuthEnabled: true,
LastActive: 0,
},
}
// CreateClientFixtures inserts known entities into the database for testing.
func CreateClientFixtures() {
for _, entity := range ClientFixtures {
Db().Create(&entity)
}
}

View File

@@ -0,0 +1,102 @@
package entity
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewClient(t *testing.T) {
m := NewClient()
assert.Equal(t, "", m.AuthScope)
assert.Equal(t, m.AuthScope, m.Scope())
m.SetScope(" metrics WEBdav!")
assert.Equal(t, "metrics webdav", m.AuthScope)
assert.Equal(t, m.AuthScope, m.Scope())
}
func TestFindClient(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
expected := ClientFixtures.Get("alice")
m := FindClient("cs5gfen1bgxz7s9i")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, m.UserUID, UserFixtures.Get("alice").UserUID)
assert.Equal(t, expected.ClientUID, m.UID())
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Bob", func(t *testing.T) {
expected := ClientFixtures.Get("bob")
m := FindClient("cs5gfsvbd7ejzn8m")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, m.UserUID, UserFixtures.Get("bob").UserUID)
assert.Equal(t, expected.ClientUID, m.UID())
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Metrics", func(t *testing.T) {
expected := ClientFixtures.Get("metrics")
m := FindClient("cs5cpu17n6gj2qo5")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Empty(t, m.UserUID)
assert.Equal(t, expected.ClientUID, m.UID())
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
}
func TestClient_HasPassword(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
expected := ClientFixtures.Get("alice")
m := FindClient("cs5gfen1bgxz7s9i")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, expected.ClientUID, m.UID())
assert.False(t, m.HasSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.False(t, m.HasSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.False(t, m.HasSecret(""))
assert.True(t, m.WrongSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.True(t, m.WrongSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.True(t, m.WrongSecret(""))
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Metrics", func(t *testing.T) {
expected := ClientFixtures.Get("metrics")
m := FindClient("cs5cpu17n6gj2qo5")
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, expected.ClientUID, m.UID())
assert.True(t, m.HasSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.False(t, m.HasSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.False(t, m.HasSecret(""))
assert.False(t, m.WrongSecret("xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.True(t, m.WrongSecret("aaCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"))
assert.True(t, m.WrongSecret(""))
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -37,7 +38,7 @@ type Session struct {
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider" yaml:"AuthProvider,omitempty"`
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod" yaml:"AuthMethod,omitempty"`
AuthDomain string `gorm:"type:VARBINARY(255);default:'';" json:"AuthDomain" yaml:"AuthDomain,omitempty"`
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
AuthID string `gorm:"type:VARBINARY(255);index;default:'';" json:"AuthID" yaml:"AuthID,omitempty"`
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope" yaml:"AuthScope,omitempty"`
LastActive int64 `json:"LastActive" yaml:"LastActive,omitempty"`
SessExpires int64 `gorm:"index" json:"Expires" yaml:"Expires,omitempty"`
@@ -64,7 +65,7 @@ func (Session) TableName() string {
}
// NewSession creates a new session using the maxAge and timeout in seconds.
func NewSession(maxAge, timeout int64) (m *Session) {
func NewSession(lifetime, timeout int64) (m *Session) {
created := TimeStamp()
m = &Session{
@@ -74,8 +75,8 @@ func NewSession(maxAge, timeout int64) (m *Session) {
UpdatedAt: created,
}
if maxAge > 0 {
m.SessExpires = created.Unix() + maxAge
if lifetime > 0 {
m.SessExpires = created.Unix() + lifetime
}
if timeout > 0 {
@@ -230,6 +231,8 @@ func (m *Session) BeforeCreate(scope *gorm.Scope) error {
func (m *Session) User() *User {
if m.user != nil {
return m.user
} else if m.UserUID == "" {
return &User{}
}
if u := FindUserByUID(m.UserUID); u != nil {
@@ -453,6 +456,25 @@ func (m *Session) HasShares() bool {
}
}
// NoUser checks if this session has no specific user assigned.
func (m *Session) NoUser() bool {
return !m.HasUser()
}
// HasUser checks if a user account is assigned to the session.
func (m *Session) HasUser() bool {
return m.UserUID != ""
}
// HasRegisteredUser checks if the session belongs to a registered user.
func (m *Session) HasRegisteredUser() bool {
if !m.HasUser() {
return false
}
return m.User().IsRegistered()
}
// HasShare if the session includes the specified share
func (m *Session) HasShare(uid string) bool {
if user := m.User(); user.IsRegistered() {
@@ -495,6 +517,15 @@ func (m *Session) ExpiresAt() time.Time {
return time.Unix(m.SessExpires, 0)
}
// ExpiresIn returns the expiration time in seconds.
func (m *Session) ExpiresIn() int64 {
if m.SessExpires <= 0 {
return 0
}
return m.SessExpires - UnixTime()
}
// TimeoutAt returns the time at which the session will expire due to inactivity.
func (m *Session) TimeoutAt() time.Time {
if m.SessTimeout <= 0 || m.LastActive <= 0 {
@@ -548,6 +579,9 @@ func (m *Session) Invalid() bool {
// Valid checks whether the session belongs to a registered user or a visitor with shares.
func (m *Session) Valid() bool {
if m.AuthMethod == authn.MethodOAuth2.String() {
return true
}
return m.User().IsRegistered() || m.IsVisitor() && m.HasShares()
}
@@ -624,3 +658,13 @@ func (m *Session) HttpStatus() int {
return http.StatusUnauthorized
}
// Scope returns the client IP address, or "unknown" if it is unknown.
func (m *Session) Scope() string {
return clean.Scope(m.AuthScope)
}
// HasScope returns the client IP address, or "unknown" if it is unknown.
func (m *Session) HasScope(scope string) bool {
return !list.ParseAttr(m.Scope()).Contains(scope)
}

View File

@@ -13,7 +13,7 @@ var CheckTokens = true
// GenerateToken returns a random string token.
func GenerateToken() string {
return rnd.GenerateToken(8)
return rnd.Base36(8)
}
// InvalidDownloadToken checks if the token is unknown.

View File

@@ -301,8 +301,8 @@ func (m *User) Delete() (err error) {
return err
}
// Deleted checks if the user account has been deleted.
func (m *User) Deleted() bool {
// IsDeleted checks if the user account has been deleted.
func (m *User) IsDeleted() bool {
if m.DeletedAt == nil {
return false
}
@@ -361,8 +361,8 @@ func (m *User) BeforeCreate(scope *gorm.Scope) error {
return scope.SetColumn("UserUID", m.UserUID)
}
// Expired checks if the user account has expired.
func (m *User) Expired() bool {
// IsExpired checks if the user account has expired.
func (m *User) IsExpired() bool {
if m.ExpiresAt == nil {
return false
}
@@ -370,16 +370,20 @@ func (m *User) Expired() bool {
return m.ExpiresAt.Before(time.Now())
}
// Disabled checks if the user account has been deleted or has expired.
func (m *User) Disabled() bool {
return m.Deleted() || m.Expired() && !m.SuperAdmin
// IsDisabled checks if the user account has been deleted or has expired.
func (m *User) IsDisabled() bool {
if m == nil {
return true
}
return m.IsDeleted() || m.IsExpired() && !m.SuperAdmin
}
// UpdateLoginTime updates the login timestamp and returns it if successful.
func (m *User) UpdateLoginTime() *time.Time {
if m == nil {
return nil
} else if m.Deleted() {
} else if m.IsDeleted() {
return nil
}
@@ -398,40 +402,43 @@ func (m *User) UpdateLoginTime() *time.Time {
func (m *User) CanLogIn() bool {
if m == nil {
return false
} else if m.Deleted() || m.HasProvider(authn.ProviderNone) {
} else if m.IsDeleted() || m.HasProvider(authn.ProviderNone) {
return false
} else if !m.CanLogin && !m.SuperAdmin || m.ID <= 0 || m.UserName == "" {
return false
} else if role := m.AclRole(); m.Disabled() || role == acl.RoleUnknown {
} else if m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() {
return false
} else {
return acl.Resources.Allow(acl.ResourceConfig, role, acl.AccessOwn)
return acl.Resources.Allow(acl.ResourceConfig, m.AclRole(), acl.AccessOwn)
}
}
// CanUseWebDAV checks whether the user is allowed to use WebDAV to synchronize files.
func (m *User) CanUseWebDAV() bool {
if m == nil {
// Abort check if user is nil for any reason.
return false
} else if m.Deleted() || m.HasProvider(authn.ProviderNone) {
return false
} else if role := m.AclRole(); m.Disabled() || !m.WebDAV || m.ID <= 0 || m.UserName == "" || role == acl.RoleUnknown {
} else if !m.WebDAV || m.ID <= 0 || m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() || m.HasProvider(authn.ProviderNone) {
// Deny WebDAV access if WebDAV is disabled, the user does not have a
// regular, registered account, or the account has been deactivated.
return false
} else {
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
// Check if the ACL allows downloading files via WebDAV based on the user role.
return acl.Resources.Allow(acl.ResourceWebDAV, m.AclRole(), acl.ActionDownload)
}
}
// CanUpload checks if the user is allowed to upload files.
func (m *User) CanUpload() bool {
if m == nil {
// Abort check if user is nil for any reason.
return false
} else if m.Deleted() || m.HasProvider(authn.ProviderNone) {
return false
} else if role := m.AclRole(); m.Disabled() || role == acl.RoleUnknown {
} else if m.IsDisabled() || m.HasProvider(authn.ProviderNone) || m.IsUnknown() {
// Deny uploading if the user is unknown or the account has been deactivated.
return false
} else {
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
// Check if the ACL allows uploading photos based on the user role.
return acl.Resources.Allow(acl.ResourcePhotos, m.AclRole(), acl.ActionUpload)
}
}
@@ -630,8 +637,8 @@ func (m *User) SetRole(role string) *User {
}
// HasRole checks the user role specified as string.
func (m *User) HasRole(role string) bool {
return m.AclRole().String() == acl.ValidRoles[clean.Role(role)].String()
func (m *User) HasRole(role acl.Role) bool {
return m.AclRole() == role
}
// AclRole returns the user role for ACL permission checks.
@@ -686,13 +693,14 @@ func (m *User) Attr() string {
return clean.Attr(m.UserAttr)
}
// IsRegistered checks if the user is registered e.g. has a username.
// IsRegistered checks if this user has a registered account with a valid ID, username, and role.
func (m *User) IsRegistered() bool {
if m == nil {
return false
}
return m.UserName != "" && rnd.IsUID(m.UserUID, UserUID) && !m.IsVisitor()
// Registered users must have an ID, a UID, a username and a known role, except visitor.
return m.ID > 0 && m.UserName != "" && rnd.IsUID(m.UserUID, UserUID) && !m.IsVisitor()
}
// NotRegistered checks if the user is not registered with an own account.
@@ -743,7 +751,11 @@ func (m *User) HasSharedAccessOnly(resource acl.Resource) bool {
// IsUnknown checks if the user is unknown.
func (m *User) IsUnknown() bool {
return !rnd.IsUID(m.UserUID, UserUID) || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID
if m == nil {
return true
}
return !rnd.IsUID(m.UserUID, UserUID) || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID || m.HasRole(acl.RoleUnknown)
}
// DeleteSessions deletes all active user sessions except those passed as argument.

View File

@@ -588,59 +588,82 @@ func TestUser_String(t *testing.T) {
func TestUser_Admin(t *testing.T) {
t.Run("SuperAdmin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true}
assert.True(t, p.IsAdmin())
})
t.Run("RoleAdmin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.True(t, p.IsAdmin())
})
t.Run("NoID", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.False(t, p.IsAdmin())
})
t.Run("False", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: ""}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: ""}
assert.False(t, p.IsAdmin())
})
}
func TestUser_Anonymous(t *testing.T) {
t.Run("True", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", DisplayName: ""}
func TestUser_IsUnknown(t *testing.T) {
t.Run("ID", func(t *testing.T) {
p := User{ID: UnknownUser.ID, UserUID: "u000000000000008", UserName: "", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
assert.True(t, p.IsUnknown())
})
t.Run("False", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true}
t.Run("UID", func(t *testing.T) {
p := User{ID: 123, UserUID: "", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
assert.True(t, p.IsUnknown())
})
t.Run("Name", func(t *testing.T) {
p := User{ID: 123, UserUID: "u000000000000008", UserName: "", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
assert.False(t, p.IsUnknown())
})
t.Run("Role", func(t *testing.T) {
p := User{ID: 123, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: ""}
assert.True(t, p.IsUnknown())
})
t.Run("Admin", func(t *testing.T) {
p := User{ID: 123, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
assert.False(t, p.IsUnknown())
})
t.Run("SuperAdmin", func(t *testing.T) {
p := User{ID: 123, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true, UserRole: ""}
assert.False(t, p.IsUnknown())
})
}
func TestUser_Guest(t *testing.T) {
func TestUser_IsVisitor(t *testing.T) {
t.Run("True", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleVisitor.String()}
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleVisitor.String()}
assert.True(t, p.IsVisitor())
})
t.Run("False", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", DisplayName: ""}
t.Run("Unknown", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
assert.False(t, p.IsVisitor())
})
t.Run("Admin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.False(t, p.IsVisitor())
})
}
func TestUser_SetPassword(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
if err := p.SetPassword("insecure"); err != nil {
t.Fatal(err)
}
})
t.Run("NotRegistered", func(t *testing.T) {
p := User{UserUID: "", UserName: "Hanna", DisplayName: ""}
p := User{ID: 0, UserUID: "", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.Error(t, p.SetPassword("insecure"))
})
t.Run("PasswordTooShort", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.Error(t, p.SetPassword("cat"))
})
t.Run("PasswordTooLong", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.Error(t, p.SetPassword("hfnoehurhgfoeuro7584othgiyruifh85hglhiryhgbbyeirygbubgirgtheuogfugfkhsbdgiyerbgeuigbdtiyrgehbik"))
})
}
@@ -657,10 +680,10 @@ func TestUser_InitLogin(t *testing.T) {
}
})
t.Run("AlreadyExists", func(t *testing.T) {
p := User{UserUID: "u000000000000010", UserName: "Hans", DisplayName: ""}
p := User{ID: 10, UserUID: "u000000000000010", UserName: "Hans", DisplayName: "", UserRole: acl.RoleAdmin.String()}
if err := p.Save(); err != nil {
t.Logf("cannot user %s: ", err)
t.Logf("failed to create user: %s", err)
}
if err := p.SetPassword("insecure"); err != nil {
@@ -691,25 +714,31 @@ func TestUser_InitLogin(t *testing.T) {
func TestUser_AclRole(t *testing.T) {
t.Run("SuperAdmin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true, UserRole: ""}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: true, UserRole: ""}
assert.Equal(t, acl.RoleAdmin, p.AclRole())
assert.True(t, p.IsAdmin())
assert.False(t, p.IsVisitor())
})
t.Run("RoleAdmin", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
assert.Equal(t, acl.RoleAdmin, p.AclRole())
assert.True(t, p.IsAdmin())
assert.False(t, p.IsVisitor())
})
t.Run("NoName", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "", DisplayName: "", UserRole: acl.RoleAdmin.String()}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "", DisplayName: "", UserRole: acl.RoleAdmin.String()}
assert.Equal(t, acl.RoleVisitor, p.AclRole())
assert.False(t, p.IsAdmin())
assert.True(t, p.IsVisitor())
})
t.Run("NoID", func(t *testing.T) {
p := User{ID: 0, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: "", SuperAdmin: false, UserRole: acl.RoleAdmin.String()}
assert.Equal(t, acl.RoleAdmin, p.AclRole())
assert.False(t, p.IsAdmin())
assert.False(t, p.IsVisitor())
})
t.Run("Unauthorized", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
p := User{ID: 8, UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
assert.Equal(t, acl.RoleUnknown, p.AclRole())
assert.False(t, p.IsAdmin())
assert.False(t, p.IsVisitor())
@@ -931,27 +960,27 @@ func TestDeleteUser(t *testing.T) {
}
func TestUser_Deleted(t *testing.T) {
assert.False(t, UserFixtures.Pointer("alice").Deleted())
assert.True(t, UserFixtures.Pointer("deleted").Deleted())
assert.False(t, UserFixtures.Pointer("alice").IsDeleted())
assert.True(t, UserFixtures.Pointer("deleted").IsDeleted())
}
func TestUser_Expired(t *testing.T) {
t.Run("False", func(t *testing.T) {
assert.False(t, UserFixtures.Pointer("alice").Expired())
assert.False(t, UserFixtures.Pointer("deleted").Expired())
assert.False(t, UserFixtures.Pointer("alice").IsExpired())
assert.False(t, UserFixtures.Pointer("deleted").IsExpired())
})
t.Run("True", func(t *testing.T) {
u := NewUser()
var expired = time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC)
u.ExpiresAt = &expired
assert.True(t, u.Expired())
assert.True(t, u.IsExpired())
})
}
func TestUser_Disabled(t *testing.T) {
assert.False(t, UserFixtures.Pointer("alice").Disabled())
assert.True(t, UserFixtures.Pointer("deleted").Disabled())
assert.False(t, UserFixtures.Pointer("alice").IsDisabled())
assert.True(t, UserFixtures.Pointer("deleted").IsDisabled())
}
func TestUser_UpdateLoginTime(t *testing.T) {

View File

@@ -22,6 +22,7 @@ var Entities = Tables{
UserDetails{}.TableName(): &UserDetails{},
UserSettings{}.TableName(): &UserSettings{},
Session{}.TableName(): &Session{},
Client{}.TableName(): &Client{},
Service{}.TableName(): &Service{},
Folder{}.TableName(): &Folder{},
Duplicate{}.TableName(): &Duplicate{},

View File

@@ -7,8 +7,11 @@ import (
// Day specified as time.Duration to improve readability.
const Day = time.Hour * 24
// UnixMinute is one minute in UnixTime.
const UnixMinute int64 = 60
// UnixHour is one hour in UnixTime.
const UnixHour int64 = 3600
const UnixHour = UnixMinute * 60
// UnixDay is one day in UnixTime.
const UnixDay = UnixHour * 24
@@ -16,6 +19,12 @@ const UnixDay = UnixHour * 24
// UnixWeek is one week in UnixTime.
const UnixWeek = UnixDay * 7
// UnixMonth is about one month in UnixTime.
const UnixMonth = UnixDay * 31
// UnixYear is about one year in UnixTime.
const UnixYear = UnixDay * 365
// UTC returns the current Coordinated Universal Time (UTC).
func UTC() time.Time {
return time.Now().UTC()

View File

@@ -29,6 +29,7 @@ func CreateTestFixtures() {
CreateFaceFixtures()
CreateUserFixtures()
CreateSessionFixtures()
CreateClientFixtures()
CreateReactionFixtures()
CreatePasswordFixtures()
CreateUserShareFixtures()

View File

@@ -69,7 +69,7 @@ func NewUserLink(shareUid, userUid string) Link {
result := Link{
LinkUID: rnd.GenerateUID(LinkUID),
ShareUID: shareUid,
LinkToken: rnd.GenerateToken(10),
LinkToken: rnd.Base36(10),
CreatedBy: userUid,
CreatedAt: now,
ModifiedAt: now,

View File

@@ -19,10 +19,11 @@ func (m PasswordMap) Pointer(name string) *Password {
}
var PasswordFixtures = PasswordMap{
"alice": NewPassword("uqxetse3cy5eo9z2", "Alice123!", false),
"bob": NewPassword("uqxc08w3d0ej2283", "Bobbob123!", false),
"friend": NewPassword("uqxqg7i1kperxvu7", "!Friend321", false),
"fowler": NewPassword("urinotv3d6jedvlm", "PleaseChange$42", false),
"alice": NewPassword("uqxetse3cy5eo9z2", "Alice123!", false),
"bob": NewPassword("uqxc08w3d0ej2283", "Bobbob123!", false),
"friend": NewPassword("uqxqg7i1kperxvu7", "!Friend321", false),
"fowler": NewPassword("urinotv3d6jedvlm", "PleaseChange$42", false),
"metrics": NewPassword("cs5cpu17n6gj2qo5", "xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e", false),
}
// CreatePasswordFixtures inserts known entities into the database for testing.

76
internal/form/client.go Normal file
View File

@@ -0,0 +1,76 @@
package form
import (
"github.com/urfave/cli"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Client represents client application settings.
type Client struct {
UserUID string `json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:64;index;" json:"UserName" yaml:"UserName,omitempty"`
ClientName string `json:"ClientName,omitempty" yaml:"ClientName,omitempty"`
AuthMethod string `json:"AuthMethod,omitempty" yaml:"AuthMethod,omitempty"`
AuthScope string `json:"AuthScope,omitempty" yaml:"AuthScope,omitempty"`
AuthExpires int64 `json:"AuthExpires,omitempty" yaml:"AuthExpires,omitempty"`
AuthTokens int64 `json:"AuthTokens,omitempty" yaml:"AuthTokens,omitempty"`
AuthEnabled bool `json:"AuthEnabled,omitempty" yaml:"AuthEnabled,omitempty"`
}
// NewClient creates new client application settings.
func NewClient() Client {
return Client{
UserUID: "",
UserName: "",
ClientName: "",
AuthMethod: authn.MethodOAuth2.String(),
AuthScope: "",
AuthExpires: 3600,
AuthTokens: 5,
AuthEnabled: true,
}
}
// NewClientFromCli creates a new form with values from a CLI context.
func NewClientFromCli(ctx *cli.Context) Client {
f := NewClient()
f.ClientName = clean.Name(ctx.String("name"))
f.AuthScope = clean.Scope(ctx.String("scope"))
if method := clean.Scope(ctx.String("method")); authn.MethodOAuth2.Equal(method) {
f.AuthMethod = authn.MethodOAuth2.String()
} else if authn.MethodBasic.Equal(method) {
f.AuthMethod = authn.MethodBasic.String()
}
if authn.MethodOAuth2.NotEqual(f.AuthMethod) {
f.AuthScope = "webdav"
}
if user := ctx.String("user"); rnd.IsUID(user, 'u') {
f.UserUID = user
} else if user != "" {
f.UserName = user
}
return f
}
// Name returns the sanitized client name.
func (f *Client) Name() string {
return clean.Name(f.ClientName)
}
// Method returns the sanitized auth method name.
func (f *Client) Method() authn.MethodType {
return authn.Method(f.AuthMethod)
}
// Scope returns the client scopes as sanitized string.
func (f Client) Scope() string {
return clean.Scope(f.AuthScope)
}

View File

@@ -0,0 +1,39 @@
package form
import (
"fmt"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// ClientCredentials represents client authentication request credentials.
type ClientCredentials struct {
ClientID string `form:"client_id" json:"client_id,omitempty"`
ClientSecret string `form:"client_secret" json:" client_secret,omitempty"`
AuthScope string `form:"scope" json:"scope,omitempty"`
}
// Validate checks the grant type and credentials.
func (f ClientCredentials) Validate() error {
// Check client ID.
if f.ClientID == "" {
return fmt.Errorf("missing client id")
} else if rnd.InvalidUID(f.ClientID, 'c') {
return fmt.Errorf("invalid client id")
}
// Check client secret.
if f.ClientSecret == "" {
return fmt.Errorf("missing client secret")
} else if !rnd.IsAlnum(f.ClientSecret) {
return fmt.Errorf("invalid client secret")
}
return nil
}
// Scope returns the client scopes as sanitized string.
func (f ClientCredentials) Scope() string {
return clean.Scope(f.AuthScope)
}

View File

@@ -0,0 +1,17 @@
package form
import (
"testing"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/stretchr/testify/assert"
)
func TestNewClient(t *testing.T) {
t.Run("Defaults", func(t *testing.T) {
client := NewClient()
assert.Equal(t, authn.MethodOAuth2, client.Method())
assert.Equal(t, "", client.Scope())
})
}

View File

@@ -30,7 +30,7 @@ func TestIndexRelated(t *testing.T) {
t.Fatal(err)
}
testToken := rnd.GenerateToken(8)
testToken := rnd.Base36(8)
testPath := filepath.Join(conf.OriginalsPath(), testToken)
for _, f := range testRelated.Files {
@@ -91,7 +91,7 @@ func TestIndexRelated(t *testing.T) {
t.Fatal(err)
}
testToken := rnd.GenerateToken(8)
testToken := rnd.Base36(8)
testPath := filepath.Join(conf.OriginalsPath(), testToken)
for _, f := range testRelated.Files {

42
internal/query/clients.go Normal file
View File

@@ -0,0 +1,42 @@
package query
import (
"strings"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/rnd"
)
// Clients finds clients and returns them.
func Clients(limit, offset int, sortOrder, search string) (result entity.Clients, err error) {
result = entity.Clients{}
stmt := Db()
search = strings.TrimSpace(search)
if search == "all" {
// Don't filter.
} else if rnd.IsUID(search, entity.ClientUID) {
stmt = stmt.Where("client_uid = ?", search)
} else if rnd.IsUID(search, entity.UserUID) {
stmt = stmt.Where("user_uid = ?", search)
} else if search != "" {
stmt = stmt.Where("client_name LIKE ? OR user_name LIKE ?", search+"%", search+"%")
}
if sortOrder == "" {
sortOrder = "user_name, client_name, created_at"
}
if limit > 0 {
stmt = stmt.Limit(limit)
if offset > 0 {
stmt = stmt.Offset(offset)
}
}
err = stmt.Order(sortOrder).Find(&result).Error
return result, err
}

View File

@@ -1,13 +1,9 @@
package server
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -24,63 +20,68 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
// Authentication cache with an expiration time of 5 minutes.
// To improve performance, we use a basic auth cache
// with an expiration time of about 5 minutes.
var basicAuthExpiration = 5 * time.Minute
var basicAuthCache = gc.New(basicAuthExpiration, basicAuthExpiration)
var basicAuthMutex = sync.Mutex{}
var BasicAuthRealm = "Basic realm=\"WebDAV Authorization Required\""
// GetAuthUser returns the authenticated user if found, nil otherwise.
func GetAuthUser(key string) *entity.User {
user, valid := basicAuthCache.Get(key)
// WebDAVAuth checks authentication and authentication
// before WebDAV requests are processed.
func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
// Helper function that extracts the login information from the request headers.
var basicAuth = func(c *gin.Context) (username, password, cacheKey string, authorized bool) {
// Extract credentials from the HTTP request headers.
username, password, cacheKey = api.BasicAuth(c)
if valid && user != nil {
return user.(*entity.User)
}
return nil
}
// BasicAuth implements an HTTP request handler that adds basic authentication.
func BasicAuth(conf *config.Config) gin.HandlerFunc {
var validate = func(c *gin.Context) (name, password, key string, valid bool) {
name, password, key = GetCredentials(c)
if name == "" || password == "" {
return name, password, "", false
// Fail if the username or password is empty, as
// this is not allowed under any circumstances.
if username == "" || password == "" || cacheKey == "" {
return "", "", "", false
}
key = fmt.Sprintf("%x", sha1.Sum([]byte(key)))
if user := GetAuthUser(key); user != nil {
c.Set(gin.AuthUserKey, user)
return name, password, key, true
// To improve performance, check the cache for already authorized users.
if user, found := basicAuthCache.Get(cacheKey); found && user != nil {
// Add cached user information to the request context.
c.Set(gin.AuthUserKey, user.(*entity.User))
// Credentials have already been authorized within the configured
// expiration time of the basic auth cache (about 5 minutes).
return username, password, cacheKey, true
} else {
// Credentials found, but not pre-authorized. If successful, the
// authorization will be cached for the next request.
return username, password, cacheKey, false
}
return name, password, key, false
}
// Authentication handler that is called before WebDAV requests are processed.
return func(c *gin.Context) {
if c == nil {
return
}
name, password, key, ok := validate(c)
// Get basic authentication credentials.
username, password, cacheKey, authorized := basicAuth(c)
if ok {
// Already authenticated.
// Allow requests from already authorized users to be processed.
if authorized {
return
} else if key == "" {
// Incomplete credentials.
}
// Re-request authentication if credentials are missing or incomplete.
if cacheKey == "" {
c.Header("WWW-Authenticate", BasicAuthRealm)
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// Get client IP address.
// Get the client IP address from the request headers
// for use in logs and to enforce request rate limits.
clientIp := api.ClientIP(c)
// Check limit for failed auth requests (max. 10 per minute).
// Check the authentication request rate to block the client after
// too many failed attempts (10/req per minute by default).
if limiter.Login.Reject(clientIp) {
limiter.Abort(c)
return
@@ -91,7 +92,7 @@ func BasicAuth(conf *config.Config) gin.HandlerFunc {
// User credentials.
f := form.Login{
UserName: name,
UserName: username,
Password: password,
}
@@ -99,31 +100,31 @@ func BasicAuth(conf *config.Config) gin.HandlerFunc {
if user, _, err := entity.Auth(f, nil, c); err != nil {
message := err.Error()
limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
} else if user == nil {
message := "account not found"
limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
} else if !user.CanUseWebDAV() {
// Sync disabled for this account.
message := "sync disabled"
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
} else if err = os.MkdirAll(filepath.Join(conf.OriginalsPath(), user.GetUploadPath()), fs.ModeDir); err != nil {
message := "failed to create user upload path"
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(username))
event.LoginError(clientIp, "webdav", username, api.UserAgent(c), message)
} else {
// Successfully authenticated.
event.AuditInfo([]string{clientIp, "webdav login as %s", "succeeded"}, clean.LogQuote(name))
event.LoginInfo(clientIp, "webdav", name, api.UserAgent(c))
event.AuditInfo([]string{clientIp, "webdav login as %s", "succeeded"}, clean.LogQuote(username))
event.LoginInfo(clientIp, "webdav", username, api.UserAgent(c))
// Cache successful authentication.
basicAuthCache.SetDefault(key, user)
basicAuthCache.SetDefault(cacheKey, user)
c.Set(gin.AuthUserKey, user)
return
}
@@ -133,28 +134,3 @@ func BasicAuth(conf *config.Config) gin.HandlerFunc {
c.AbortWithStatus(http.StatusUnauthorized)
}
}
// GetCredentials parses the "Authorization" header into username and password.
func GetCredentials(c *gin.Context) (name, password, raw string) {
data := c.GetHeader("Authorization")
if !strings.HasPrefix(data, "Basic ") {
return "", "", data
}
data = strings.TrimPrefix(data, "Basic ")
auth, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return "", "", data
}
credentials := strings.SplitN(string(auth), ":", 2)
if len(credentials) != 2 {
return "", "", data
}
return credentials[0], credentials[1], data
}

View File

@@ -0,0 +1,6 @@
package header
const (
SessionID = "X-Session-ID"
Authorization = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
)

View File

@@ -32,6 +32,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.CreateSession(APIv1)
api.GetSession(APIv1)
api.DeleteSession(APIv1)
api.CreateOauthToken(APIv1)
// Server Config.
api.GetConfigOptions(APIv1)

View File

@@ -31,11 +31,11 @@ func registerWebDAVRoutes(router *gin.Engine, conf *config.Config) {
info = ""
}
WebDAV(conf.OriginalsPath(), router.Group(conf.BaseUri(WebDAVOriginals), BasicAuth(conf)), conf)
WebDAV(conf.OriginalsPath(), router.Group(conf.BaseUri(WebDAVOriginals), WebDAVAuth(conf)), conf)
log.Infof("webdav: shared %s/%s", conf.BaseUri(WebDAVOriginals), info)
if conf.ImportPath() != "" {
WebDAV(conf.ImportPath(), router.Group(conf.BaseUri(WebDAVImport), BasicAuth(conf)), conf)
WebDAV(conf.ImportPath(), router.Group(conf.BaseUri(WebDAVImport), WebDAVAuth(conf)), conf)
log.Infof("webdav: shared %s/%s", conf.BaseUri(WebDAVImport), info)
}
}

8
pkg/authn/client.go Normal file
View File

@@ -0,0 +1,8 @@
package authn
// API client types.
const (
ClientConfidential = "confidential"
ClientWebDAV = "webdav"
ClientUnknown = ""
)

68
pkg/authn/methods.go Normal file
View File

@@ -0,0 +1,68 @@
package authn
import (
"strings"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
)
// MethodType represents an authentication method.
type MethodType string
// Authentication methods.
const (
MethodDefault MethodType = "default"
MethodOAuth2 MethodType = "oauth2"
MethodBasic MethodType = "basic"
MethodUnknown MethodType = ""
)
// IsDefault checks if this is the default method.
func (t MethodType) IsDefault() bool {
return t.String() == MethodDefault.String()
}
// String returns the provider identifier as a string.
func (t MethodType) String() string {
switch t {
case "":
return string(MethodDefault)
case "oauth":
return string(MethodOAuth2)
default:
return string(t)
}
}
// Equal checks if the type matches.
func (t MethodType) Equal(s string) bool {
return strings.EqualFold(s, t.String())
}
// NotEqual checks if the type is different.
func (t MethodType) NotEqual(s string) bool {
return !t.Equal(s)
}
// Pretty returns the provider identifier in an easy-to-read format.
func (t MethodType) Pretty() string {
switch t {
case MethodOAuth2:
return "OAuth2"
default:
return txt.UpperFirst(t.String())
}
}
// Method casts a string to a normalized method type.
func Method(s string) MethodType {
switch s {
case "", "-", "null", "nil", "0", "false":
return MethodDefault
case "oauth2", "oauth":
return MethodOAuth2
default:
return MethodType(clean.TypeLower(s))
}
}

View File

@@ -1,6 +1,8 @@
package authn
import (
"strings"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/txt"
@@ -12,6 +14,7 @@ type ProviderType string
// Authentication providers.
const (
ProviderDefault ProviderType = "default"
ProviderClient ProviderType = "client"
ProviderLocal ProviderType = "local"
ProviderLDAP ProviderType = "ldap"
ProviderLink ProviderType = "link"
@@ -58,6 +61,16 @@ func (t ProviderType) String() string {
}
}
// Equal checks if the type matches.
func (t ProviderType) Equal(s string) bool {
return strings.EqualFold(s, t.String())
}
// NotEqual checks if the type is different.
func (t ProviderType) NotEqual(s string) bool {
return !t.Equal(s)
}
// Pretty returns the provider identifier in an easy-to-read format.
func (t ProviderType) Pretty() string {
switch t {

16
pkg/clean/scope.go Normal file
View File

@@ -0,0 +1,16 @@
package clean
import (
"strings"
"github.com/photoprism/photoprism/pkg/list"
)
// Scope sanitizes a string that contains authentication scope identifiers.
func Scope(s string) string {
if s == "" {
return ""
}
return list.ParseAttr(strings.ToLower(s)).String()
}

22
pkg/clean/scope_test.go Normal file
View File

@@ -0,0 +1,22 @@
package clean
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestScope(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
q := Scope("")
assert.Equal(t, "", q)
})
t.Run("Sanitized", func(t *testing.T) {
q := Scope(" foo:BAR webdav openid metrics !")
assert.Equal(t, "foo:bar metrics openid webdav", q)
})
t.Run("All", func(t *testing.T) {
q := Scope("*")
assert.Equal(t, "*", q)
})
}

View File

@@ -27,6 +27,9 @@ func Key(s string) string {
switch r {
case '.', '@', '-', '+', '_', '#':
return r
case '*':
i++
return r
}
if r >= '0' && r <= '9' {
return r
@@ -52,7 +55,7 @@ func Value(s string) string {
return -1
}
switch r {
case '(', ')', '<', '>', '\'', '"':
case '(', ')', '<', '>', '\'', '"', '*':
return r
}
return r

View File

@@ -67,7 +67,7 @@ func TestFlag_String(t *testing.T) {
assert.Equal(t, "feature:string", ParseKeyValue("feature : string").String())
})
t.Run("WhitespacePadding", func(t *testing.T) {
assert.Equal(t, "featureq62:String!!#$^&*(", ParseKeyValue(" ^&^&(&*&)feature!q62:String!!#$^&*( ").String())
assert.Equal(t, "*featureq62:String!!#$^&*(", ParseKeyValue(" ^&^&(&*&)feature!q62:String!!#$^&*( ").String())
})
t.Run("SpecialChars", func(t *testing.T) {
assert.Equal(t, "feature:String!!#$^&*(", ParseKeyValue(" feature:String!!#$^&*( ").String())

View File

@@ -68,3 +68,36 @@ func (list Attr) Sort() {
}
})
}
// Contains tests if the list contains the attribute provided as string.
func (list Attr) Contains(s string) bool {
if len(list) == 0 || s == "" {
return false
} else if s == All {
return true
}
attr := ParseKeyValue(s)
// Abort if attribute is invalid.
if attr.Key == "" {
return false
}
// Find matches.
if attr.Value == "" || attr.Value == All {
for i := range list {
if strings.EqualFold(attr.Key, list[i].Key) || list[i].Key == All {
return true
}
}
} else {
for i := range list {
if strings.EqualFold(attr.Key, list[i].Key) && (attr.Value == list[i].Value || list[i].Value == All) {
return true
}
}
}
return false
}

View File

@@ -1,12 +1,5 @@
package report
const (
Enabled = "Enabled"
Disabled = ""
Yes = "Yes"
No = ""
)
// Bool returns t or f, depending on the value of b.
func Bool(value bool, yes, no string) string {
if value {

10
pkg/report/const.go Normal file
View File

@@ -0,0 +1,10 @@
package report
const (
Enabled = "Enabled"
Disabled = "Disabled"
Yes = "Yes"
No = "No"
NotAssigned = "N/A"
Never = "Never"
)

38
pkg/report/credentials.go Normal file
View File

@@ -0,0 +1,38 @@
package report
import (
"bytes"
"github.com/olekukonko/tablewriter"
)
// Credentials returns a text-formatted table with credentials.
func Credentials(idName, idValue, secretName, secretValue string) string {
buf := &bytes.Buffer{}
// Set borders.
borders := tablewriter.Border{
Left: true,
Right: true,
Top: true,
Bottom: true,
}
// Set values.
rows := make([][]string, 2)
rows[0] = []string{idName, secretName}
rows[1] = []string{idValue, secretValue}
// Render table.
table := tablewriter.NewWriter(buf)
table.SetRowLine(true)
table.SetAutoWrapText(false)
table.SetHeader(nil)
table.SetBorders(borders)
table.SetCenterSeparator("|")
table.AppendBulk(rows)
table.Render()
return buf.String()
}

View File

@@ -1,34 +0,0 @@
package rnd
import (
"crypto/rand"
"math/big"
)
const CharsetBase36 = "abcdefghijklmnopqrstuvwxyz0123456789"
const CharsetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// Base36 generates a random token containing lowercase letters and numbers.
func Base36(length int) string {
return Charset(length, CharsetBase36)
}
// Base62 generates a random token containing upper and lower case letters as well as numbers.
func Base62(length int) string {
return Charset(length, CharsetBase62)
}
// Charset generates a random token with the specified length and charset.
func Charset(length int, charset string) string {
max := big.NewInt(int64(len(charset)))
b := make([]byte, length)
for i := range b {
if r, err := rand.Int(rand.Reader, max); err == nil {
b[i] = charset[r.Int64()]
}
}
return string(b)
}

View File

@@ -1,46 +0,0 @@
package rnd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBase36(t *testing.T) {
token := Base36(10)
t.Logf("Base36 Token: %s", token)
assert.NotEmpty(t, token)
assert.True(t, IsRefID(token))
assert.False(t, InvalidRefID(token))
assert.Equal(t, 10, len(token))
for n := 0; n < 10; n++ {
token = Base36(10)
t.Logf("Base36 %d: %s", n, token)
assert.NotEmpty(t, token)
}
}
func TestBase62(t *testing.T) {
token := Base62(23)
t.Logf("Base62 Token: %s", token)
assert.NotEmpty(t, token)
assert.False(t, IsRefID(token))
assert.True(t, InvalidRefID(token))
assert.Equal(t, 23, len(token))
for n := 0; n < 10; n++ {
token = Base62(10)
t.Logf("Base62 %d: %s", n, token)
assert.NotEmpty(t, token)
}
}
func TestCharset(t *testing.T) {
token := Charset(23, CharsetBase62)
t.Logf("Charset Token: %s", token)
assert.NotEmpty(t, token)
assert.False(t, IsRefID(token))
assert.True(t, InvalidRefID(token))
assert.Equal(t, 23, len(token))
}

View File

@@ -10,9 +10,9 @@ import (
func CrcToken() string {
token := make([]byte, 0, 14)
token = append(token, []byte(GenerateToken(4))...)
token = append(token, []byte(Base36(4))...)
token = append(token, '-')
token = append(token, []byte(GenerateToken(4))...)
token = append(token, []byte(Base36(4))...)
checksum := crc32.ChecksumIEEE(token)

View File

@@ -8,9 +8,9 @@ import (
func TestGeneratePasscode(t *testing.T) {
for n := 0; n < 10; n++ {
code := GeneratePasscode()
t.Logf("Passcode %d: %s", n, code)
assert.Equal(t, 19, len(code))
s := GeneratePasscode()
t.Logf("Passcode %d: %s", n, s)
assert.Equal(t, 19, len(s))
}
}

View File

@@ -2,31 +2,38 @@ package rnd
import (
"crypto/rand"
"encoding/binary"
"fmt"
"strconv"
"math/big"
)
// GenerateToken returns a random token with length of up to 10 characters.
func GenerateToken(size uint) string {
if size > 10 || size < 1 {
panic(fmt.Sprintf("size out of range: %d", size))
}
const CharsetBase36 = "abcdefghijklmnopqrstuvwxyz0123456789"
const CharsetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, 0, 14)
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
panic(err)
}
randomInt := binary.BigEndian.Uint64(b)
result = append(result, strconv.FormatUint(randomInt, 36)...)
for i := len(result); i < cap(result); i++ {
result = append(result, byte(123-(cap(result)-i)))
}
return string(result[:size])
// Base36 generates a random token containing lowercase letters and numbers.
func Base36(length int) string {
return Charset(length, CharsetBase36)
}
// Base62 generates a random token containing upper and lower case letters as well as numbers.
func Base62(length int) string {
return Charset(length, CharsetBase62)
}
// Charset generates a random token with the specified length and charset.
func Charset(length int, charset string) string {
if length < 1 {
return ""
} else if length > 4096 {
length = 4096
}
m := big.NewInt(int64(len(charset)))
b := make([]byte, length)
for i := range b {
if r, err := rand.Int(rand.Reader, m); err == nil {
b[i] = charset[r.Int64()]
}
}
return string(b)
}

View File

@@ -6,32 +6,94 @@ import (
"github.com/stretchr/testify/assert"
)
func TestBase36(t *testing.T) {
t.Run("10", func(t *testing.T) {
s := Base36(10)
t.Logf("Base36 (10 chars): %s", s)
assert.NotEmpty(t, s)
assert.True(t, IsRefID(s))
assert.False(t, InvalidRefID(s))
assert.Equal(t, 10, len(s))
for n := 0; n < 10; n++ {
s = Base36(10)
t.Logf("Base36 %d: %s", n, s)
assert.NotEmpty(t, s)
}
})
t.Run("23", func(t *testing.T) {
s := Base36(23)
t.Logf("Base36 (23 chars): %s", s)
assert.NotEmpty(t, s)
assert.False(t, IsRefID(s))
assert.True(t, InvalidRefID(s))
assert.Equal(t, 23, len(s))
})
}
func TestBase62(t *testing.T) {
t.Run("10", func(t *testing.T) {
for n := 0; n < 10; n++ {
s := Base62(10)
t.Logf("Base62 %d: %s", n, s)
assert.NotEmpty(t, s)
}
})
t.Run("23", func(t *testing.T) {
s := Base62(23)
t.Logf("Base62 (23 chars): %s", s)
assert.NotEmpty(t, s)
assert.False(t, IsRefID(s))
assert.True(t, InvalidRefID(s))
assert.Equal(t, 23, len(s))
})
t.Run("32", func(t *testing.T) {
for n := 0; n < 10; n++ {
s := Base62(32)
t.Logf("Base62 (32 chars) %d: %s", n, s)
assert.NotEmpty(t, s)
assert.False(t, IsRefID(s))
assert.True(t, InvalidRefID(s))
assert.Equal(t, 32, len(s))
}
})
}
func TestCharset(t *testing.T) {
s := Charset(23, CharsetBase62)
t.Logf("CharsetBase62 (23 chars): %s", s)
assert.NotEmpty(t, s)
assert.False(t, IsRefID(s))
assert.True(t, InvalidRefID(s))
assert.Equal(t, 23, len(s))
}
func TestRandomToken(t *testing.T) {
t.Run("Size4", func(t *testing.T) {
token := GenerateToken(4)
assert.NotEmpty(t, token)
s := Base36(4)
assert.NotEmpty(t, s)
})
t.Run("Size8", func(t *testing.T) {
token := GenerateToken(9)
assert.NotEmpty(t, token)
s := Base36(9)
assert.NotEmpty(t, s)
})
t.Run("Log", func(t *testing.T) {
for n := 0; n < 10; n++ {
token := GenerateToken(8)
t.Logf("%d: %s", n, token)
assert.NotEmpty(t, token)
s := Base36(8)
t.Logf("%d: %s", n, s)
assert.NotEmpty(t, s)
}
})
}
func BenchmarkGenerateToken4(b *testing.B) {
for n := 0; n < b.N; n++ {
GenerateToken(4)
Base36(4)
}
}
func BenchmarkGenerateToken3(b *testing.B) {
for n := 0; n < b.N; n++ {
GenerateToken(3)
Base36(3)
}
}

View File

@@ -15,7 +15,7 @@ func GenerateUID(prefix byte) string {
result := make([]byte, 0, 16)
result = append(result, prefix)
result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...)
result = append(result, GenerateToken(9)...)
result = append(result, Base36(9)...)
return string(result)
}

View File

@@ -22,8 +22,9 @@ func TestIsUID(t *testing.T) {
prefix := byte('x')
for n := 0; n < 10; n++ {
id := GenerateUID(prefix)
assert.True(t, IsUID(id, prefix))
s := GenerateUID(prefix)
t.Logf("UID %d: %s", n, s)
assert.True(t, IsUID(s, prefix))
}
assert.True(t, IsUID("lt9k3pw1wowuy3c2", 'l'))
@@ -69,9 +70,10 @@ func TestIsAlnum(t *testing.T) {
func TestGenerateUID(t *testing.T) {
for n := 0; n < 5; n++ {
uid := GenerateUID('x')
t.Logf("id: %s", uid)
uid := GenerateUID('c')
t.Logf("UID %d: %s", n, uid)
assert.Equal(t, len(uid), 16)
assert.True(t, IsUID(uid, 'c'))
}
}

View File

@@ -8,9 +8,9 @@ import (
func TestUUID(t *testing.T) {
for n := 0; n < 5; n++ {
uuid := UUID()
t.Logf("token: %s", uuid)
assert.Equal(t, 36, len(uuid))
s := UUID()
t.Logf("UUID %d: %s", n, s)
assert.Equal(t, 36, len(s))
}
}