diff --git a/backend/embed/api_docs/api.swagger.json b/backend/embed/api_docs/api.swagger.json index 04957aac..7946b9cb 100644 --- a/backend/embed/api_docs/api.swagger.json +++ b/backend/embed/api_docs/api.swagger.json @@ -10,6 +10,24 @@ "$ref": "file://./paths/get.json" } }, + "/auth": { + "get": { + "$ref": "file://./paths/auth/get.json" + }, + "post": { + "$ref": "file://./paths/auth/post.json" + } + }, + "/auth/refresh": { + "post": { + "$ref": "file://./paths/auth/refresh/post.json" + } + }, + "/auth/sse": { + "post": { + "$ref": "file://./paths/auth/sse/post.json" + } + }, "/certificates": { "get": { "$ref": "file://./paths/certificates/get.json" @@ -155,19 +173,6 @@ "$ref": "file://./paths/streams/streamID/delete.json" } }, - "/tokens": { - "get": { - "$ref": "file://./paths/tokens/get.json" - }, - "post": { - "$ref": "file://./paths/tokens/post.json" - } - }, - "/tokens/sse": { - "post": { - "$ref": "file://./paths/tokens/sse/post.json" - } - }, "/upstreams": { "get": { "$ref": "file://./paths/upstreams/get.json" @@ -219,6 +224,9 @@ }, "components": { "schemas": { + "AuthConfigObject": { + "$ref": "file://./components/AuthConfigObject.json" + }, "CertificateAuthorityList": { "$ref": "file://./components/CertificateAuthorityList.json" }, diff --git a/backend/embed/api_docs/components/AuthConfigObject.json b/backend/embed/api_docs/components/AuthConfigObject.json new file mode 100644 index 00000000..68d363a3 --- /dev/null +++ b/backend/embed/api_docs/components/AuthConfigObject.json @@ -0,0 +1,13 @@ +{ + "type": "array", + "description": "AuthConfigObject", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "local", + "ldap", + "oidc" + ] + } +} diff --git a/backend/embed/api_docs/components/UserObject.json b/backend/embed/api_docs/components/UserObject.json index c142cebe..d7f3328e 100644 --- a/backend/embed/api_docs/components/UserObject.json +++ b/backend/embed/api_docs/components/UserObject.json @@ -7,7 +7,6 @@ "created_at", "updated_at", "name", - "nickname", "email", "is_disabled" ], @@ -29,12 +28,7 @@ "name": { "type": "string", "minLength": 2, - "maxLength": 100 - }, - "nickname": { - "type": "string", - "minLength": 2, - "maxLength": 100 + "maxLength": 50 }, "email": { "type": "string", diff --git a/backend/embed/api_docs/paths/auth/get.json b/backend/embed/api_docs/paths/auth/get.json new file mode 100644 index 00000000..e34da6c3 --- /dev/null +++ b/backend/embed/api_docs/paths/auth/get.json @@ -0,0 +1,28 @@ +{ + "operationId": "getAuthConfig", + "summary": "Returns auth configuration", + "tags": ["Auth"], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["result"], + "properties": { + "result": { + "$ref": "#/components/schemas/AuthConfigObject" + } + } + }, + "examples": { + "default": { + "value": "todo" + } + } + } + } + } + } +} diff --git a/backend/embed/api_docs/paths/tokens/post.json b/backend/embed/api_docs/paths/auth/post.json similarity index 98% rename from backend/embed/api_docs/paths/tokens/post.json rename to backend/embed/api_docs/paths/auth/post.json index 9c047cc4..2cd55d3e 100644 --- a/backend/embed/api_docs/paths/tokens/post.json +++ b/backend/embed/api_docs/paths/auth/post.json @@ -1,7 +1,7 @@ { "operationId": "requestToken", "summary": "Request a new access token from credentials", - "tags": ["Tokens"], + "tags": ["Auth"], "requestBody": { "description": "Credentials Payload", "required": true, diff --git a/backend/embed/api_docs/paths/tokens/get.json b/backend/embed/api_docs/paths/auth/refresh/post.json similarity index 96% rename from backend/embed/api_docs/paths/tokens/get.json rename to backend/embed/api_docs/paths/auth/refresh/post.json index 95d175f6..b44bf95c 100644 --- a/backend/embed/api_docs/paths/tokens/get.json +++ b/backend/embed/api_docs/paths/auth/refresh/post.json @@ -1,7 +1,7 @@ { "operationId": "refreshToken", "summary": "Refresh your access token", - "tags": ["Tokens"], + "tags": ["Auth"], "responses": { "200": { "description": "200 response", diff --git a/backend/embed/api_docs/paths/tokens/sse/post.json b/backend/embed/api_docs/paths/auth/sse/post.json similarity index 96% rename from backend/embed/api_docs/paths/tokens/sse/post.json rename to backend/embed/api_docs/paths/auth/sse/post.json index 1646f84f..0e53181a 100644 --- a/backend/embed/api_docs/paths/tokens/sse/post.json +++ b/backend/embed/api_docs/paths/auth/sse/post.json @@ -1,7 +1,7 @@ { "operationId": "requestSSEToken", "summary": "Request a new SSE token", - "tags": ["Tokens"], + "tags": ["Auth"], "responses": { "200": { "description": "200 response", diff --git a/backend/embed/api_docs/paths/users/get.json b/backend/embed/api_docs/paths/users/get.json index 9d8ea419..4adf79ff 100644 --- a/backend/embed/api_docs/paths/users/get.json +++ b/backend/embed/api_docs/paths/users/get.json @@ -28,7 +28,7 @@ "type": "string" }, "description": "The sorting of the list", - "example": "name,nickname.desc,email.asc" + "example": "name,email.asc" } ], "responses": { @@ -57,10 +57,6 @@ "field": "name", "direction": "ASC" }, - { - "field": "nickname", - "direction": "DESC" - }, { "field": "email", "direction": "ASC" @@ -70,7 +66,6 @@ { "id": 1, "name": "Jamie Curnow", - "nickname": "James", "email": "jc@jc21.com", "created_at": 1578010090000, "updated_at": 1578010095000, @@ -81,7 +76,6 @@ { "id": 2, "name": "John Doe", - "nickname": "John", "email": "johdoe@example.com", "created_at": 1578010100000, "updated_at": 1578010105000, @@ -95,7 +89,6 @@ { "id": 3, "name": "Jane Doe", - "nickname": "Jane", "email": "janedoe@example.com", "created_at": 1578010110000, "updated_at": 1578010115000, diff --git a/backend/embed/api_docs/paths/users/post.json b/backend/embed/api_docs/paths/users/post.json index e2f270d3..2c404598 100644 --- a/backend/embed/api_docs/paths/users/post.json +++ b/backend/embed/api_docs/paths/users/post.json @@ -31,7 +31,6 @@ "result": { "id": 1, "name": "Jamie Curnow", - "nickname": "James", "email": "jc@jc21.com", "created_at": 1578010100000, "updated_at": 1578010100000, diff --git a/backend/embed/api_docs/paths/users/userID/get.json b/backend/embed/api_docs/paths/users/userID/get.json index ace15a83..dab25824 100644 --- a/backend/embed/api_docs/paths/users/userID/get.json +++ b/backend/embed/api_docs/paths/users/userID/get.json @@ -43,7 +43,6 @@ "result": { "id": 1, "name": "Jamie Curnow", - "nickname": "James", "email": "jc@jc21.com", "created_at": 1578010100000, "updated_at": 1578010105000, diff --git a/backend/embed/api_docs/paths/users/userID/put.json b/backend/embed/api_docs/paths/users/userID/put.json index c1bb9fa8..1a2b0358 100644 --- a/backend/embed/api_docs/paths/users/userID/put.json +++ b/backend/embed/api_docs/paths/users/userID/put.json @@ -52,7 +52,6 @@ "result": { "id": 1, "name": "Jamie Curnow", - "nickname": "James", "email": "jc@jc21.com", "created_at": 1578010100000, "updated_at": 1578010110000, diff --git a/backend/embed/migrations/mysql/20201013035318_initial_schema.sql b/backend/embed/migrations/mysql/20201013035318_initial_schema.sql index f2ce4c63..4f2726bc 100644 --- a/backend/embed/migrations/mysql/20201013035318_initial_schema.sql +++ b/backend/embed/migrations/mysql/20201013035318_initial_schema.sql @@ -17,7 +17,6 @@ CREATE TABLE IF NOT EXISTS `user` `updated_at` BIGINT NOT NULL DEFAULT 0, `is_deleted` INT NOT NULL DEFAULT 0, -- int on purpose, gormism `name` VARCHAR(50) NOT NULL, - `nickname` VARCHAR(50) NOT NULL, `email` VARCHAR(255) NOT NULL, `is_system` BOOLEAN NOT NULL DEFAULT FALSE, `is_disabled` BOOLEAN NOT NULL DEFAULT FALSE @@ -45,6 +44,7 @@ CREATE TABLE IF NOT EXISTS `auth` `is_deleted` INT NOT NULL DEFAULT 0, -- int on purpose, gormism `user_id` INT NOT NULL, `type` VARCHAR(50) NOT NULL, + `identity` VARCHAR(255) NOT NULL, `secret` VARCHAR(255) NOT NULL, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE, UNIQUE (`user_id`, `type`) diff --git a/backend/embed/migrations/mysql/20201013035839_initial_data.sql b/backend/embed/migrations/mysql/20201013035839_initial_data.sql index 924ea478..6814fe0e 100644 --- a/backend/embed/migrations/mysql/20201013035839_initial_data.sql +++ b/backend/embed/migrations/mysql/20201013035839_initial_data.sql @@ -37,6 +37,27 @@ INSERT INTO `setting` ( "default-site", "What to show users who hit your Nginx server by default", '"welcome"' -- remember this is json +), +( + ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), + ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), + "auth-methods", + "Which methods are enabled for authentication", + '["local"]' -- remember this is json +), +( + ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), + ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), + "oidc-auth", + "Configuration for OIDC authentication", + '{}' -- remember this is json +), +( + ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), + ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), + "ldap-auth", + "Configuration for LDAP authentication", + '{"host": "", "dn": "", "sync_by": "uid"}' -- remember this is json ); -- Default Certificate Authorities diff --git a/backend/embed/migrations/postgres/20201013035318_initial_schema.sql b/backend/embed/migrations/postgres/20201013035318_initial_schema.sql index b68bbb0d..fa75dd7d 100644 --- a/backend/embed/migrations/postgres/20201013035318_initial_schema.sql +++ b/backend/embed/migrations/postgres/20201013035318_initial_schema.sql @@ -15,7 +15,6 @@ CREATE TABLE "user" ( "updated_at" BIGINT NOT NULL DEFAULT 0, "is_deleted" INTEGER NOT NULL DEFAULT 0, -- int on purpose, gormism "name" VARCHAR(50) NOT NULL, - "nickname" VARCHAR(50) NOT NULL, "email" VARCHAR(255) NOT NULL, "is_system" BOOLEAN NOT NULL DEFAULT FALSE, "is_disabled" BOOLEAN NOT NULL DEFAULT FALSE @@ -39,6 +38,7 @@ CREATE TABLE "auth" ( "is_deleted" INTEGER NOT NULL DEFAULT 0, -- int on purpose, gormism "user_id" INTEGER NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, "type" VARCHAR(50) NOT NULL, + "identity" VARCHAR(255) NOT NULL, "secret" VARCHAR(255) NOT NULL, UNIQUE ("user_id", "type") ); diff --git a/backend/embed/migrations/postgres/20201013035839_initial_data.sql b/backend/embed/migrations/postgres/20201013035839_initial_data.sql index 9dde41f3..97126f83 100644 --- a/backend/embed/migrations/postgres/20201013035839_initial_data.sql +++ b/backend/embed/migrations/postgres/20201013035839_initial_data.sql @@ -37,6 +37,27 @@ INSERT INTO "setting" ( 'default-site', 'What to show users who hit your Nginx server by default', '"welcome"' -- remember this is json +), +( + EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, + EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, + 'auth-methods', + 'Which methods are enabled for authentication', + '["local"]' -- remember this is json +), +( + EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, + EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, + 'oidc-auth', + 'Configuration for OIDC authentication', + '{}' -- remember this is json +), +( + EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, + EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, + 'ldap-auth', + 'Configuration for LDAP authentication', + '{"host": "", "dn": "", "sync_by": "uid"}' -- remember this is json ); -- Default Certificate Authorities diff --git a/backend/embed/migrations/sqlite/20201013035318_initial_schema.sql b/backend/embed/migrations/sqlite/20201013035318_initial_schema.sql index 1a896a6b..602be032 100644 --- a/backend/embed/migrations/sqlite/20201013035318_initial_schema.sql +++ b/backend/embed/migrations/sqlite/20201013035318_initial_schema.sql @@ -17,7 +17,6 @@ CREATE TABLE IF NOT EXISTS `user` `updated_at` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, - `nickname` TEXT NOT NULL, `email` TEXT NOT NULL, `is_system` INTEGER NOT NULL DEFAULT 0, `is_disabled` INTEGER NOT NULL DEFAULT 0 @@ -45,6 +44,7 @@ CREATE TABLE IF NOT EXISTS `auth` `is_deleted` INTEGER NOT NULL DEFAULT 0, `user_id` INTEGER NOT NULL, `type` TEXT NOT NULL, + `identity` TEXT NOT NULL, `secret` TEXT NOT NULL, FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, UNIQUE (`user_id`, `type`) diff --git a/backend/embed/migrations/sqlite/20201013035839_initial_data.sql b/backend/embed/migrations/sqlite/20201013035839_initial_data.sql index 6ef00c53..48bd18dc 100644 --- a/backend/embed/migrations/sqlite/20201013035839_initial_data.sql +++ b/backend/embed/migrations/sqlite/20201013035839_initial_data.sql @@ -36,6 +36,27 @@ INSERT INTO `setting` ( "default-site", "What to show users who hit your Nginx server by default", '"welcome"' -- remember this is json +), +( + unixepoch() * 1000, + unixepoch() * 1000, + "auth-methods", + "Which methods are enabled for authentication", + '["local"]' -- remember this is json +), +( + unixepoch() * 1000, + unixepoch() * 1000, + "oidc-auth", + "Configuration for OIDC authentication", + '{}' -- remember this is json +), +( + unixepoch() * 1000, + unixepoch() * 1000, + "ldap-auth", + "Configuration for LDAP authentication", + '{"host": "", "dn": "", "sync_by": "uid"}' -- remember this is json ); -- Default Certificate Authorities diff --git a/backend/go.mod b/backend/go.mod index f728fc33..746c8390 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -32,14 +32,17 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/alexflint/go-scalar v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect + github.com/go-ldap/ldap/v3 v3.4.8 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect diff --git a/backend/go.sum b/backend/go.sum index 1ca58e33..f124f74d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,7 +1,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y= github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= @@ -26,12 +29,16 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A= github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80= +github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= +github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -47,6 +54,12 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= @@ -61,6 +74,12 @@ github.com/jc21/go-sse v0.0.0-20230307071053-2e6b1dbcb7ec h1:KKntwkZlM2w/88QiDyA github.com/jc21/go-sse v0.0.0-20230307071053-2e6b1dbcb7ec/go.mod h1:4v5Xmm0eYuaWqKJ63XUV5YfQPoxtId3DgDytbnWhi+s= github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760 h1:7wxq2DIgtO36KLrFz1RldysO0WVvcYsD49G9tyAs01k= github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760/go.mod h1:yIq2t51OJgVsdRlPY68NAnyVdBH0kYXxDTFtUxOap80= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= @@ -136,40 +155,90 @@ github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/stretchr/objx v0.1.0/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= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vrischmann/envconfig v1.3.0 h1:4XIvQTXznxmWMnjouj0ST5lFo/WAYf5Exgl3x82crEk= github.com/vrischmann/envconfig v1.3.0/go.mod h1:bbvxFYJdRSpXrhS63mBFtKJzkDiNkyArOLXtY6q0kuI= github.com/wacul/ptr v1.0.0/go.mod h1:BD0gjsZrCwtoR+yWDB9v2hQ8STlq9tT84qKfa+3txOc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 h1:qXafrlZL1WsJW5OokjraLLRURHiw0OzKHD/RNdspp4w= github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04/go.mod h1:FiwNQxz6hGoNFBC4nIx+CxZhI3nne5RmIOlT/MXcSD4= gitlab.com/jc21com/sqlite v1.22.2-0.20230527022643-b56cedb3bc85 h1:NPHauobrOymc80Euu+e0tsMyXcdtLCX5bQPKX5zsI38= gitlab.com/jc21com/sqlite v1.22.2-0.20230527022643-b56cedb3bc85/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210326220804-49726bf1d181/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/backend/internal/api/handler/auth.go b/backend/internal/api/handler/auth.go index a6399680..e362c4ac 100644 --- a/backend/internal/api/handler/auth.go +++ b/backend/internal/api/handler/auth.go @@ -3,97 +3,244 @@ package handler import ( "encoding/json" "net/http" + h "npm/internal/api/http" + "npm/internal/errors" + "npm/internal/logger" + "slices" "time" c "npm/internal/api/context" - h "npm/internal/api/http" "npm/internal/entity/auth" + "npm/internal/entity/setting" "npm/internal/entity/user" - "npm/internal/errors" - "npm/internal/logger" + njwt "npm/internal/jwt" "gorm.io/gorm" ) -type setAuthModel struct { - // The json tags are required, as the change password form decodes into this object - Type string `json:"type"` - Secret string `json:"secret"` - CurrentSecret string `json:"current_secret"` +// tokenPayload is the structure we expect from a incoming login request +type tokenPayload struct { + Type string `json:"type"` + Identity string `json:"identity"` + Secret string `json:"secret"` } -// SetAuth sets a auth method. This can be used for "me" and `2` for example -// Route: POST /users/:userID/auth -func SetAuth() func(http.ResponseWriter, *http.Request) { +// GetAuthConfig is anonymous and returns the types of authentication +// enabled for this site +func GetAuthConfig() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { + val, err := setting.GetAuthMethods() + if err == gorm.ErrRecordNotFound { + h.ResultResponseJSON(w, r, http.StatusOK, nil) + return + } else if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + h.ResultResponseJSON(w, r, http.StatusOK, val) + } +} + +// NewToken Also known as a Login, requesting a new token with credentials +// Route: POST /auth +func NewToken() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + // Read the bytes from the body bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) - var newAuth setAuthModel - err := json.Unmarshal(bodyBytes, &newAuth) + var payload tokenPayload + err := json.Unmarshal(bodyBytes, &payload) if err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) return } - userID, isSelf, userIDErr := getUserIDFromRequest(r) - if userIDErr != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, userIDErr.Error(), nil) + // Check that this auth type is enabled + if authMethods, err := setting.GetAuthMethods(); err == gorm.ErrRecordNotFound { + h.ResultResponseJSON(w, r, http.StatusOK, nil) + return + } else if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } else if !slices.Contains(authMethods, payload.Type) { + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidAuthType.Error(), nil) return } - // Load user - thisUser, thisUserErr := user.GetByID(userID) - if thisUserErr == gorm.ErrRecordNotFound { - h.NotFound(w, r) - return - } else if thisUserErr != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, thisUserErr.Error(), nil) - return + switch payload.Type { + case "ldap": + newTokenLDAP(w, r, payload) + case "oidc": + newTokenOIDC(w, r, payload) + case "local": + newTokenLocal(w, r, payload) + } + } +} + +func newTokenLocal(w http.ResponseWriter, r *http.Request, payload tokenPayload) { + // Find user by email + userObj, userErr := user.GetByEmail(payload.Identity) + if userErr != nil { + logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), userErr.Error()) + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) + return + } + + if userObj.IsDisabled { + h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil) + return + } + + // Get Auth + authObj, authErr := auth.GetByUserIDType(userObj.ID, payload.Type) + if authErr != nil { + logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), authErr.Error()) + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) + return + } + + // Verify Auth + validateErr := authObj.ValidateSecret(payload.Secret) + if validateErr != nil { + logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), validateErr.Error()) + // Sleep for 1 second to prevent brute force password guessing + time.Sleep(time.Second) + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) + return + } + + if response, err := njwt.Generate(&userObj, false); err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, response) + } +} + +func newTokenLDAP(w http.ResponseWriter, r *http.Request, payload tokenPayload) { + // Get LDAP settings + ldapSettings, err := setting.GetLDAPSettings() + if err != nil { + logger.Error("LDAP settings not found", err) + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + // Lets try to authenticate with LDAP + ldapUser, err := auth.LDAPAuthenticate(payload.Identity, payload.Secret) + if err != nil { + logger.Error("LDAP Auth Error", err) + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) + return + } + + // Get Auth by identity + authObj, authErr := auth.GetByIdenityType(ldapUser.Username, payload.Type) + if authErr == gorm.ErrRecordNotFound { + // Auth is not found for this identity. We can create it + if !ldapSettings.AutoCreateUser { + // LDAP Login was successful, but user does not have an auth record + // and auto create is disabled. Showing account disabled error + // for the time being + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrUserDisabled.Error(), nil) + return + } + + // Attempt to find user by email + foundUser, err := user.GetByEmail(ldapUser.Email) + if err == gorm.ErrRecordNotFound { + // User not found, create user + foundUser, err = user.CreateFromLDAPUser(ldapUser) + if err != nil { + logger.Error("user.CreateFromLDAPUser", err) + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + logger.Info("Created user from LDAP: %s, %s", ldapUser.Username, foundUser.Email) + } else if err != nil { + logger.Error("user.GetByEmail", err) + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + // Create auth record and attach to this user + authObj = auth.Model{ + UserID: foundUser.ID, + Type: auth.TypeLDAP, + Identity: ldapUser.Username, + } + if err := authObj.Save(); err != nil { + logger.Error("auth.Save", err) + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + logger.Info("Created LDAP auth for user: %s, %s", ldapUser.Username, foundUser.Email) + } else if authErr != nil { + logger.Error("auth.GetByIdenityType", err) + h.ResultErrorJSON(w, r, http.StatusInternalServerError, authErr.Error(), nil) + return + } + + userObj, userErr := user.GetByID(authObj.UserID) + if userErr != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, userErr.Error(), nil) + return + } + + if userObj.IsDisabled { + h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil) + return + } + + if response, err := njwt.Generate(&userObj, false); err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, response) + } +} + +func newTokenOIDC(w http.ResponseWriter, r *http.Request, _ tokenPayload) { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, "NOT YET SUPPORTED", nil) +} + +// RefreshToken an existing token by given them a new one with the same claims +// Route: POST /auth/refresh +func RefreshToken() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + // TODO: Use your own methods to verify an existing user is + // able to refresh their token and then give them a new one + userObj, _ := user.GetByEmail("jc@jc21.com") + if response, err := njwt.Generate(&userObj, false); err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, response) + } + } +} + +// NewSSEToken will generate and return a very short lived token for +// use by the /sse/* endpoint. It requires an app token to generate this +// Route: POST /auth/sse +func NewSSEToken() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + userID := r.Context().Value(c.UserIDCtxKey).(uint) + + // Find user + userObj, userErr := user.GetByID(userID) + if userErr != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) + return + } + + if userObj.IsDisabled { + h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil) + return + } + + if response, err := njwt.Generate(&userObj, true); err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + } else { + h.ResultResponseJSON(w, r, http.StatusOK, response) } - - if thisUser.IsSystem { - h.ResultErrorJSON(w, r, http.StatusBadRequest, "Cannot set password for system user", nil) - return - } - - // Load existing auth for user - userAuth, userAuthErr := auth.GetByUserIDType(userID, newAuth.Type) - if userAuthErr != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, userAuthErr.Error(), nil) - return - } - - if isSelf { - // confirm that the current_secret given is valid for the one stored in the database - validateErr := userAuth.ValidateSecret(newAuth.CurrentSecret) - if validateErr != nil { - logger.Debug("%s: %s", "Password change: current password was incorrect", validateErr.Error()) - // Sleep for 1 second to prevent brute force password guessing - time.Sleep(time.Second) - - h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrCurrentPasswordInvalid.Error(), nil) - return - } - } - - if newAuth.Type == auth.TypePassword { - err := userAuth.SetPassword(newAuth.Secret) - if err != nil { - logger.Error("SetPasswordError", err) - h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) - } - } - - if err = userAuth.Save(); err != nil { - logger.Error("AuthSaveError", err) - h.ResultErrorJSON(w, r, http.StatusInternalServerError, "Unable to save Authentication for User", nil) - return - } - - userAuth.Secret = "" - - // todo: add to audit-log - - h.ResultResponseJSON(w, r, http.StatusOK, userAuth) } } diff --git a/backend/internal/api/handler/settings.go b/backend/internal/api/handler/settings.go index 0e573c54..b6c90890 100644 --- a/backend/internal/api/handler/settings.go +++ b/backend/internal/api/handler/settings.go @@ -64,6 +64,12 @@ func CreateSetting() func(http.ResponseWriter, *http.Request) { return } + // Check if the setting already exists + if _, err := setting.GetByName(newSetting.Name); err == nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Setting with name '%s' already exists", newSetting.Name), nil) + return + } + if err = newSetting.Save(); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Setting: %s", err.Error()), nil) return @@ -75,6 +81,7 @@ func CreateSetting() func(http.ResponseWriter, *http.Request) { // UpdateSetting updates a setting // Route: PUT /settings/{name} +// TODO: Add validation for the setting value, for system settings they should be validated against the setting name and type func UpdateSetting() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { settingName := chi.URLParam(r, "name") @@ -85,13 +92,12 @@ func UpdateSetting() func(http.ResponseWriter, *http.Request) { h.NotFound(w, r) case nil: bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) - err := json.Unmarshal(bodyBytes, &setting) - if err != nil { + if err := json.Unmarshal(bodyBytes, &setting); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) return } - if err = setting.Save(); err != nil { + if err := setting.Save(); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return } diff --git a/backend/internal/api/handler/tokens.go b/backend/internal/api/handler/tokens.go deleted file mode 100644 index 58c483f3..00000000 --- a/backend/internal/api/handler/tokens.go +++ /dev/null @@ -1,116 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - h "npm/internal/api/http" - "npm/internal/errors" - "npm/internal/logger" - "time" - - c "npm/internal/api/context" - "npm/internal/entity/auth" - "npm/internal/entity/user" - njwt "npm/internal/jwt" -) - -// tokenPayload is the structure we expect from a incoming login request -type tokenPayload struct { - Type string `json:"type"` - Identity string `json:"identity"` - Secret string `json:"secret"` -} - -// NewToken Also known as a Login, requesting a new token with credentials -// Route: POST /tokens -func NewToken() func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - // Read the bytes from the body - bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) - - var payload tokenPayload - err := json.Unmarshal(bodyBytes, &payload) - if err != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) - return - } - - // Find user - userObj, userErr := user.GetByEmail(payload.Identity) - if userErr != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) - return - } - - if userObj.IsDisabled { - h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil) - return - } - - // Get Auth - authObj, authErr := auth.GetByUserIDType(userObj.ID, payload.Type) - if authErr != nil { - logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), authErr.Error()) - h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) - return - } - - // Verify Auth - validateErr := authObj.ValidateSecret(payload.Secret) - if validateErr != nil { - logger.Debug("%s: %s", errors.ErrInvalidLogin.Error(), validateErr.Error()) - // Sleep for 1 second to prevent brute force password guessing - time.Sleep(time.Second) - h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) - return - } - - if response, err := njwt.Generate(&userObj, false); err != nil { - h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) - } else { - h.ResultResponseJSON(w, r, http.StatusOK, response) - } - } -} - -// RefreshToken an existing token by given them a new one with the same claims -// Route: GET /tokens -func RefreshToken() func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - // TODO: Use your own methods to verify an existing user is - // able to refresh their token and then give them a new one - userObj, _ := user.GetByEmail("jc@jc21.com") - if response, err := njwt.Generate(&userObj, false); err != nil { - h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) - } else { - h.ResultResponseJSON(w, r, http.StatusOK, response) - } - } -} - -// NewSSEToken will generate and return a very short lived token for -// use by the /sse/* endpoint. It requires an app token to generate this -// Route: POST /tokens/sse -func NewSSEToken() func(http.ResponseWriter, *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - userID := r.Context().Value(c.UserIDCtxKey).(uint) - - // Find user - userObj, userErr := user.GetByID(userID) - if userErr != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil) - return - } - - if userObj.IsDisabled { - h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil) - return - } - - if response, err := njwt.Generate(&userObj, true); err != nil { - h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) - } else { - h.ResultResponseJSON(w, r, http.StatusOK, response) - } - } -} diff --git a/backend/internal/api/handler/users.go b/backend/internal/api/handler/users.go index 25078318..a15848f5 100644 --- a/backend/internal/api/handler/users.go +++ b/backend/internal/api/handler/users.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "net/http" + "time" c "npm/internal/api/context" h "npm/internal/api/http" @@ -17,6 +18,13 @@ import ( "gorm.io/gorm" ) +type setAuthModel struct { + // The json tags are required, as the change password form decodes into this object + Type string `json:"type"` + Secret string `json:"secret"` + CurrentSecret string `json:"current_secret"` +} + // GetUsers returns all users // Route: GET /users func GetUsers() func(http.ResponseWriter, *http.Request) { @@ -188,7 +196,7 @@ func CreateUser() func(http.ResponseWriter, *http.Request) { // newUser has been saved, now save their auth if newUser.Auth.Secret != "" && newUser.Auth.ID == 0 { newUser.Auth.UserID = newUser.ID - if newUser.Auth.Type == auth.TypePassword { + if newUser.Auth.Type == auth.TypeLocal { err = newUser.Auth.SetPassword(newUser.Auth.Secret) if err != nil { logger.Error("SetPasswordError", err) @@ -247,3 +255,79 @@ func getUserIDFromRequest(r *http.Request) (uint, bool, error) { } return userID, self, nil } + +// SetAuth sets a auth method. This can be used for "me" and `2` for example +// Route: POST /users/:userID/auth +func SetAuth() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) + + var newAuth setAuthModel + err := json.Unmarshal(bodyBytes, &newAuth) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) + return + } + + userID, isSelf, userIDErr := getUserIDFromRequest(r) + if userIDErr != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, userIDErr.Error(), nil) + return + } + + // Load user + thisUser, thisUserErr := user.GetByID(userID) + if thisUserErr == gorm.ErrRecordNotFound { + h.NotFound(w, r) + return + } else if thisUserErr != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, thisUserErr.Error(), nil) + return + } + + if thisUser.IsSystem { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "Cannot set password for system user", nil) + return + } + + // Load existing auth for user + userAuth, userAuthErr := auth.GetByUserIDType(userID, newAuth.Type) + if userAuthErr != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, userAuthErr.Error(), nil) + return + } + + if isSelf { + // confirm that the current_secret given is valid for the one stored in the database + validateErr := userAuth.ValidateSecret(newAuth.CurrentSecret) + if validateErr != nil { + logger.Debug("%s: %s", "Password change: current password was incorrect", validateErr.Error()) + // Sleep for 1 second to prevent brute force password guessing + time.Sleep(time.Second) + + h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrCurrentPasswordInvalid.Error(), nil) + return + } + } + + if newAuth.Type == auth.TypeLocal { + err := userAuth.SetPassword(newAuth.Secret) + if err != nil { + logger.Error("SetPasswordError", err) + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + } + } + + if err = userAuth.Save(); err != nil { + logger.Error("AuthSaveError", err) + h.ResultErrorJSON(w, r, http.StatusInternalServerError, "Unable to save Authentication for User", nil) + return + } + + userAuth.Secret = "" + + // todo: add to audit-log + + h.ResultResponseJSON(w, r, http.StatusOK, userAuth) + } +} diff --git a/backend/internal/api/http/responses_test.go b/backend/internal/api/http/responses_test.go index 71dde997..f59f20f6 100644 --- a/backend/internal/api/http/responses_test.go +++ b/backend/internal/api/http/responses_test.go @@ -4,9 +4,10 @@ import ( "io" "net/http" "net/http/httptest" + "testing" + "npm/internal/entity/user" "npm/internal/model" - "testing" "github.com/qri-io/jsonschema" "github.com/stretchr/testify/assert" @@ -36,9 +37,8 @@ func TestResultResponseJSON(t *testing.T) { ModelBase: model.ModelBase{ID: 10}, Email: "me@example.com", Name: "John Doe", - Nickname: "Jonny", }, - want: "{\"result\":{\"id\":10,\"created_at\":0,\"updated_at\":0,\"name\":\"John Doe\",\"nickname\":\"Jonny\",\"email\":\"me@example.com\",\"is_disabled\":false,\"gravatar_url\":\"\"}}", + want: "{\"result\":{\"id\":10,\"created_at\":0,\"updated_at\":0,\"name\":\"John Doe\",\"email\":\"me@example.com\",\"is_disabled\":false,\"gravatar_url\":\"\"}}", }, { name: "error response", diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index e94df339..60a4d08c 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -74,12 +74,13 @@ func applyRoutes(r chi.Router) chi.Router { r.With(middleware.EnforceSetup(), middleware.Enforce()). Get("/config", handler.Config()) - // Tokens - r.With(middleware.EnforceSetup()).Route("/tokens", func(r chi.Router) { + // Auth + r.With(middleware.EnforceSetup()).Route("/auth", func(r chi.Router) { + r.Get("/", handler.GetAuthConfig()) r.With(middleware.EnforceRequestSchema(schema.GetToken())). Post("/", handler.NewToken()) r.With(middleware.Enforce()). - Get("/", handler.RefreshToken()) + Post("/refresh", handler.RefreshToken()) r.With(middleware.Enforce()). Post("/sse", handler.NewSSEToken()) }) diff --git a/backend/internal/api/schema/common.go b/backend/internal/api/schema/common.go index 9313bcb6..af02f8ea 100644 --- a/backend/internal/api/schema/common.go +++ b/backend/internal/api/schema/common.go @@ -64,6 +64,9 @@ const anyType = ` }, { "type": "integer" + }, + { + "type": "string" } ] } diff --git a/backend/internal/api/schema/create_user.go b/backend/internal/api/schema/create_user.go index ee17617a..3ad1346f 100644 --- a/backend/internal/api/schema/create_user.go +++ b/backend/internal/api/schema/create_user.go @@ -16,7 +16,6 @@ func CreateUser() string { ], "properties": { "name": %s, - "nickname": %s, "email": %s, "is_disabled": { "type": "boolean" @@ -30,7 +29,7 @@ func CreateUser() string { "properties": { "type": { "type": "string", - "pattern": "^password$" + "pattern": "^local$" }, "secret": %s } @@ -38,5 +37,5 @@ func CreateUser() string { "capabilities": %s } } - `, stringMinMax(2, 100), stringMinMax(2, 100), stringMinMax(5, 150), stringMinMax(8, 255), capabilties()) + `, stringMinMax(2, 50), stringMinMax(5, 150), stringMinMax(8, 255), capabilties()) } diff --git a/backend/internal/api/schema/get_token.go b/backend/internal/api/schema/get_token.go index fe1a9502..9188d8fc 100644 --- a/backend/internal/api/schema/get_token.go +++ b/backend/internal/api/schema/get_token.go @@ -18,7 +18,7 @@ func GetToken() string { "properties": { "type": { "type": "string", - "pattern": "^password$" + "enum": ["local", "ldap", "oidc"] }, "identity": %s, "secret": %s diff --git a/backend/internal/api/schema/set_auth.go b/backend/internal/api/schema/set_auth.go index f2df26aa..2ffdb605 100644 --- a/backend/internal/api/schema/set_auth.go +++ b/backend/internal/api/schema/set_auth.go @@ -3,6 +3,7 @@ package schema import "fmt" // SetAuth is the schema for incoming data validation +// Only local auth is supported for setting a password func SetAuth() string { return fmt.Sprintf(` { @@ -15,7 +16,7 @@ func SetAuth() string { "properties": { "type": { "type": "string", - "pattern": "^password$" + "pattern": "^local$" }, "secret": %s, "current_secret": %s diff --git a/backend/internal/api/schema/update_user.go b/backend/internal/api/schema/update_user.go index 5eb4fd8a..df4815bf 100644 --- a/backend/internal/api/schema/update_user.go +++ b/backend/internal/api/schema/update_user.go @@ -11,7 +11,6 @@ func UpdateUser() string { "minProperties": 1, "properties": { "name": %s, - "nickname": %s, "email": %s, "is_disabled": { "type": "boolean" @@ -19,5 +18,5 @@ func UpdateUser() string { "capabilities": %s } } - `, stringMinMax(2, 100), stringMinMax(2, 100), stringMinMax(5, 150), capabilties()) + `, stringMinMax(2, 50), stringMinMax(5, 150), capabilties()) } diff --git a/backend/internal/entity/auth/entity_test.go b/backend/internal/entity/auth/entity_test.go index 948473de..6c54b62f 100644 --- a/backend/internal/entity/auth/entity_test.go +++ b/backend/internal/entity/auth/entity_test.go @@ -40,7 +40,7 @@ func (s *testsuite) SetupTest() { }).AddRow( 10, 100, - TypePassword, + TypeLocal, "abc123", ) } @@ -54,7 +54,7 @@ func TestExampleTestSuite(t *testing.T) { func assertModel(t *testing.T, m Model) { assert.Equal(t, uint(10), m.ID) assert.Equal(t, uint(100), m.UserID) - assert.Equal(t, TypePassword, m.Type) + assert.Equal(t, TypeLocal, m.Type) assert.Equal(t, "abc123", m.Secret) } @@ -83,10 +83,10 @@ func (s *testsuite) TestGetByUserIDType() { s.mock. ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "auth" WHERE user_id = $1 AND type = $2 AND "auth"."is_deleted" = $3 ORDER BY "auth"."id" LIMIT $4`)). - WithArgs(100, TypePassword, 0, 1). + WithArgs(100, TypeLocal, 0, 1). WillReturnRows(s.singleRow) - m, err := GetByUserIDType(100, TypePassword) + m, err := GetByUserIDType(100, TypeLocal) require.NoError(s.T(), err) require.NoError(s.T(), s.mock.ExpectationsWereMet()) assertModel(s.T(), m) @@ -103,7 +103,7 @@ func (s *testsuite) TestSave() { sqlmock.AnyArg(), 0, 100, - TypePassword, + TypeLocal, "abc123", ). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11")) @@ -112,7 +112,7 @@ func (s *testsuite) TestSave() { // New model m := Model{ UserID: 100, - Type: TypePassword, + Type: TypeLocal, Secret: "abc123", } err := m.Save() @@ -127,7 +127,7 @@ func (s *testsuite) TestSetPassword() { m := Model{UserID: 100} err := m.SetPassword("abc123") require.NoError(s.T(), err) - assert.Equal(s.T(), TypePassword, m.Type) + assert.Equal(s.T(), TypeLocal, m.Type) assert.Greater(s.T(), len(m.Secret), 15) } @@ -143,10 +143,10 @@ func (s *testsuite) TestValidateSecret() { require.NoError(s.T(), err) err = m.ValidateSecret("this is not the password") assert.NotNil(s.T(), err) - assert.Equal(s.T(), "Invalid Password", err.Error()) + assert.Equal(s.T(), "Invalid Credentials", err.Error()) m.Type = "not a valid type" err = m.ValidateSecret("abc123") assert.NotNil(s.T(), err) - assert.Equal(s.T(), "Could not validate Secret, auth type is not a Password", err.Error()) + assert.Equal(s.T(), "Could not validate Secret, auth type is not Local", err.Error()) } diff --git a/backend/internal/entity/auth/ldap.go b/backend/internal/entity/auth/ldap.go new file mode 100644 index 00000000..d94cd74a --- /dev/null +++ b/backend/internal/entity/auth/ldap.go @@ -0,0 +1,96 @@ +package auth + +import ( + "encoding/json" + "fmt" + "strings" + + "npm/internal/entity/setting" + "npm/internal/logger" + + ldap3 "github.com/go-ldap/ldap/v3" + "github.com/rotisserie/eris" +) + +// LDAPUser is the LDAP User +type LDAPUser struct { + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` +} + +// LDAPAuthenticate will use ldap to authenticate with user/pass +func LDAPAuthenticate(identity, password string) (*LDAPUser, error) { + ldapSettings, err := setting.GetLDAPSettings() + if err != nil { + return nil, err + } + + dn := strings.Replace(ldapSettings.UserDN, "{{USERNAME}}", identity, 1) + conn, err := ldapConnect(ldapSettings.Host, dn, password) + if err != nil { + return nil, err + } + // nolint: errcheck, gosec + defer conn.Close() + return ldapSearchUser(conn, ldapSettings, identity) +} + +// Attempt ldap connection +func ldapConnect(host, dn, password string) (*ldap3.Conn, error) { + var conn *ldap3.Conn + var err error + + if conn, err = ldap3.DialURL(fmt.Sprintf("ldap://%s", host)); err != nil { + logger.Error("LdapError", err) + return nil, err + } + + logger.Debug("LDAP Logging in with: %s", dn) + if err := conn.Bind(dn, password); err != nil { + if !strings.Contains(err.Error(), "Invalid Credentials") { + logger.Error("LDAPAuthError", err) + } + // nolint: gosec, errcheck + conn.Close() + return nil, err + } + + logger.Debug("LDAP Login Successful") + return conn, nil +} + +func ldapSearchUser(l *ldap3.Conn, ldapSettings setting.LDAPSettings, username string) (*LDAPUser, error) { + // Search for the given username + searchRequest := ldap3.NewSearchRequest( + ldapSettings.BaseDN, + ldap3.ScopeWholeSubtree, + ldap3.NeverDerefAliases, + 0, + 0, + false, + strings.Replace(ldapSettings.SelfFilter, "{{USERNAME}}", username, 1), + nil, // []string{"name"}, + nil, + ) + + sr, err := l.Search(searchRequest) + if err != nil { + logger.Error("LdapError", err) + return nil, err + } + + if len(sr.Entries) < 1 { + return nil, eris.New("No user found in LDAP search") + } else if len(sr.Entries) > 1 { + j, _ := json.Marshal(sr) + logger.Debug("LDAP Search Results: %s", j) + return nil, eris.Errorf("Too many LDAP results returned in LDAP search: %d", len(sr.Entries)) + } + + return &LDAPUser{ + Username: strings.ToLower(username), + Name: sr.Entries[0].GetAttributeValue(ldapSettings.NameProperty), + Email: strings.ToLower(sr.Entries[0].GetAttributeValue(ldapSettings.EmailProperty)), + }, nil +} diff --git a/backend/internal/entity/auth/methods.go b/backend/internal/entity/auth/methods.go index e8c88354..37afd0d9 100644 --- a/backend/internal/entity/auth/methods.go +++ b/backend/internal/entity/auth/methods.go @@ -11,7 +11,7 @@ func GetByID(id int) (Model, error) { return m, err } -// GetByUserIDType finds a user by email +// GetByUserIDType finds a user by id and type func GetByUserIDType(userID uint, authType string) (Model, error) { var auth Model db := database.GetDB() @@ -21,3 +21,14 @@ func GetByUserIDType(userID uint, authType string) (Model, error) { First(&auth) return auth, result.Error } + +// GetByUserIDType finds a user by id and type +func GetByIdenityType(identity string, authType string) (Model, error) { + var auth Model + db := database.GetDB() + result := db. + Where("identity = ?", identity). + Where("type = ?", authType). + First(&auth) + return auth, result.Error +} diff --git a/backend/internal/entity/auth/model.go b/backend/internal/entity/auth/model.go index f7298a7a..729f7c58 100644 --- a/backend/internal/entity/auth/model.go +++ b/backend/internal/entity/auth/model.go @@ -8,17 +8,20 @@ import ( "golang.org/x/crypto/bcrypt" ) +// Auth types const ( - // TypePassword is the Password Type - TypePassword = "password" + TypeLocal = "local" + TypeLDAP = "ldap" + TypeOIDC = "oidc" ) // Model is the model type Model struct { model.ModelBase - UserID uint `json:"user_id" gorm:"column:user_id"` - Type string `json:"type" gorm:"column:type;default:password"` - Secret string `json:"secret,omitempty" gorm:"column:secret"` + UserID uint `json:"user_id" gorm:"column:user_id"` + Type string `json:"type" gorm:"column:type;default:local"` + Identity string `json:"identity,omitempty" gorm:"column:identity"` + Secret string `json:"secret,omitempty" gorm:"column:secret"` } // TableName overrides the table name used by gorm @@ -48,7 +51,7 @@ func (m *Model) SetPassword(password string) error { return err } - m.Type = TypePassword + m.Type = TypeLocal m.Secret = string(hash) return nil @@ -56,13 +59,13 @@ func (m *Model) SetPassword(password string) error { // ValidateSecret will check if a given secret matches the encrypted secret func (m *Model) ValidateSecret(secret string) error { - if m.Type != TypePassword { - return eris.New("Could not validate Secret, auth type is not a Password") + if m.Type != TypeLocal { + return eris.New("Could not validate Secret, auth type is not Local") } err := bcrypt.CompareHashAndPassword([]byte(m.Secret), []byte(secret)) if err != nil { - return eris.New("Invalid Password") + return eris.New("Invalid Credentials") } return nil diff --git a/backend/internal/entity/setting/auth_methods.go b/backend/internal/entity/setting/auth_methods.go new file mode 100644 index 00000000..ea6db4dd --- /dev/null +++ b/backend/internal/entity/setting/auth_methods.go @@ -0,0 +1,20 @@ +package setting + +import ( + "encoding/json" +) + +// GetAuthMethods returns the authentication methods enabled for this site +func GetAuthMethods() ([]string, error) { + var l []string + var m Model + if err := m.LoadByName("auth-methods"); err != nil { + return l, err + } + + if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil { + return l, err + } + + return l, nil +} diff --git a/backend/internal/entity/setting/ldap.go b/backend/internal/entity/setting/ldap.go new file mode 100644 index 00000000..d29e63ca --- /dev/null +++ b/backend/internal/entity/setting/ldap.go @@ -0,0 +1,32 @@ +package setting + +import ( + "encoding/json" +) + +// LDAPSettings are the settings for LDAP that come from +// the `ldap-auth` setting value +type LDAPSettings struct { + Host string `json:"host"` + BaseDN string `json:"base_dn"` + UserDN string `json:"user_dn"` + EmailProperty string `json:"email_property"` + NameProperty string `json:"name_property"` + SelfFilter string `json:"self_filter"` + AutoCreateUser bool `json:"auto_create_user"` +} + +// GetLDAPSettings will return the LDAP settings +func GetLDAPSettings() (LDAPSettings, error) { + var l LDAPSettings + var m Model + if err := m.LoadByName("ldap-auth"); err != nil { + return l, err + } + + if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil { + return l, err + } + + return l, nil +} diff --git a/backend/internal/entity/user/entity_test.go b/backend/internal/entity/user/entity_test.go index 1a789c75..e1f296e5 100644 --- a/backend/internal/entity/user/entity_test.go +++ b/backend/internal/entity/user/entity_test.go @@ -41,14 +41,12 @@ func (s *testsuite) SetupTest() { s.singleRow = sqlmock.NewRows([]string{ "id", "name", - "nickname", "email", "is_disabled", "is_system", }).AddRow( 10, "John Doe", - "Jonny", "jon@example.com", false, false, @@ -74,14 +72,12 @@ func (s *testsuite) SetupTest() { s.listRows = sqlmock.NewRows([]string{ "id", "name", - "nickname", "email", "is_disabled", "is_system", }).AddRow( 10, "John Doe", - "Jonny", "jon@example.com", false, false, @@ -104,7 +100,6 @@ func TestExampleTestSuite(t *testing.T) { func assertModel(t *testing.T, m Model) { assert.Equal(t, uint(10), m.ID) assert.Equal(t, "John Doe", m.Name) - assert.Equal(t, "Jonny", m.Nickname) assert.Equal(t, "jon@example.com", m.Email) assert.Equal(t, false, m.IsDisabled) assert.Equal(t, false, m.IsSystem) @@ -182,7 +177,7 @@ func (s *testsuite) TestSave() { WillReturnRows(s.singleRow) s.mock.ExpectBegin() - s.mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "user" ("created_at","updated_at","is_deleted","name","nickname","email","is_disabled","is_system") VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING "id"`)). + s.mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "user" ("created_at","updated_at","is_deleted","name","email","is_disabled","is_system") VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING "id"`)). WithArgs( sqlmock.AnyArg(), sqlmock.AnyArg(), @@ -199,7 +194,6 @@ func (s *testsuite) TestSave() { // New model, as system m := Model{ Name: "John Doe", - Nickname: "Jonny", Email: "JON@example.com", // mixed case on purpose IsSystem: true, } diff --git a/backend/internal/entity/user/methods.go b/backend/internal/entity/user/methods.go index 65a754f9..3909288d 100644 --- a/backend/internal/entity/user/methods.go +++ b/backend/internal/entity/user/methods.go @@ -2,8 +2,10 @@ package user import ( "fmt" + "npm/internal/database" "npm/internal/entity" + "npm/internal/entity/auth" "npm/internal/logger" "npm/internal/model" ) @@ -104,3 +106,14 @@ func GetCapabilities(userID uint) ([]string, error) { } return capabilities, nil } + +// CreateFromLDAPUser will create a user from an LDAP user object +func CreateFromLDAPUser(ldapUser *auth.LDAPUser) (Model, error) { + user := Model{ + Email: ldapUser.Email, + Name: ldapUser.Name, + } + err := user.Save() + user.generateGravatar() + return user, err +} diff --git a/backend/internal/entity/user/model.go b/backend/internal/entity/user/model.go index d00e27bd..6d71ab6e 100644 --- a/backend/internal/entity/user/model.go +++ b/backend/internal/entity/user/model.go @@ -18,7 +18,6 @@ import ( type Model struct { model.ModelBase Name string `json:"name" gorm:"column:name" filter:"name,string"` - Nickname string `json:"nickname" gorm:"column:nickname" filter:"nickname,string"` Email string `json:"email" gorm:"column:email" filter:"email,email"` IsDisabled bool `json:"is_disabled" gorm:"column:is_disabled" filter:"is_disabled,boolean"` IsSystem bool `json:"is_system,omitempty" gorm:"column:is_system" filter:"is_system,boolean"` diff --git a/backend/internal/errors/errors.go b/backend/internal/errors/errors.go index 80f864d9..16be50d2 100644 --- a/backend/internal/errors/errors.go +++ b/backend/internal/errors/errors.go @@ -10,6 +10,7 @@ var ( ErrDatabaseUnavailable = eris.New("database-unavailable") ErrDuplicateEmailUser = eris.New("email-already-exists") ErrInvalidLogin = eris.New("invalid-login-credentials") + ErrInvalidAuthType = eris.New("invalid-auth-type") ErrUserDisabled = eris.New("user-disabled") ErrSystemUserReadonly = eris.New("cannot-save-system-users") ErrValidationFailed = eris.New("request-failed-validation") diff --git a/backend/internal/model/filter.go b/backend/internal/model/filter.go index 056e6ef6..2cdf560c 100644 --- a/backend/internal/model/filter.go +++ b/backend/internal/model/filter.go @@ -7,7 +7,7 @@ type Filter struct { Value []string `json:"value"` } -// FilterMapValue ... +// FilterMapValue is the structure of a filter map value type FilterMapValue struct { Type string Field string diff --git a/backend/internal/model/pageinfo.go b/backend/internal/model/pageinfo.go index 7fe4ce49..ad77a681 100644 --- a/backend/internal/model/pageinfo.go +++ b/backend/internal/model/pageinfo.go @@ -15,7 +15,7 @@ type Sort struct { Direction string `json:"direction"` } -// GetSort ... +// GetSort is the sort array func (p *PageInfo) GetSort(def Sort) []Sort { if p.Sort == nil { return []Sort{def} diff --git a/backend/internal/model/pageinfo_test.go b/backend/internal/model/pageinfo_test.go index 5fb756e7..e3d20e83 100644 --- a/backend/internal/model/pageinfo_test.go +++ b/backend/internal/model/pageinfo_test.go @@ -18,7 +18,7 @@ func TestPageInfoGetSort(t *testing.T) { Direction: "asc", } defined := Sort{ - Field: "nickname", + Field: "email", Direction: "desc", } // default diff --git a/backend/internal/tags/filters_test.go b/backend/internal/tags/filters_test.go index 88b8c841..b6685f1c 100644 --- a/backend/internal/tags/filters_test.go +++ b/backend/internal/tags/filters_test.go @@ -18,7 +18,6 @@ func TestGetFilterSchema(t *testing.T) { ID uint `json:"id" gorm:"column:user_id" filter:"id,number"` Created time.Time `json:"created" gorm:"column:user_created_date" filter:"created,date"` Name string `json:"name" gorm:"column:user_name" filter:"name,string"` - NickName string `json:"nickname" gorm:"column:user_nickname" filter:"nickname"` IsDisabled string `json:"is_disabled" gorm:"column:user_is_disabled" filter:"is_disabled,bool"` Permissions string `json:"permissions" gorm:"column:user_permissions" filter:"permissions,regex"` History string `json:"history" gorm:"column:user_history" filter:"history,regex,(id|name)"` diff --git a/frontend/package.json b/frontend/package.json index 8800677d..dd649910 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,7 @@ "version": "3.0.0", "private": true, "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "tsc && vite build", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:ci": "yarn lint --watchAll=false", diff --git a/frontend/src/api/npm/getSSEToken.ts b/frontend/src/api/npm/getSSEToken.ts index a0f90378..c8a2a602 100644 --- a/frontend/src/api/npm/getSSEToken.ts +++ b/frontend/src/api/npm/getSSEToken.ts @@ -6,7 +6,7 @@ export async function getSSEToken( ): Promise { const { result } = await api.post( { - url: "/tokens/sse", + url: "/auth/sse", }, abortController, ); diff --git a/frontend/src/api/npm/getToken.ts b/frontend/src/api/npm/getToken.ts index ff7c5557..750016ec 100644 --- a/frontend/src/api/npm/getToken.ts +++ b/frontend/src/api/npm/getToken.ts @@ -15,7 +15,7 @@ export async function getToken( ): Promise { const { result } = await api.post( { - url: "/tokens", + url: "/auth", data: payload, }, abortController, diff --git a/frontend/src/api/npm/refreshToken.ts b/frontend/src/api/npm/refreshToken.ts index de31f401..7f448f56 100644 --- a/frontend/src/api/npm/refreshToken.ts +++ b/frontend/src/api/npm/refreshToken.ts @@ -4,9 +4,9 @@ import { TokenResponse } from "./responseTypes"; export async function refreshToken( abortController?: AbortController, ): Promise { - const { result } = await api.get( + const { result } = await api.post( { - url: "/tokens", + url: "/auth/refresh", }, abortController, ); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 60a5c22d..241b9549 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,4 +1,4 @@ -import { ReactNode, createContext, useContext, useState } from "react"; +import { createContext, ReactNode, useContext, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useIntervalWhen } from "rooks"; @@ -9,7 +9,7 @@ import AuthStore from "src/modules/AuthStore"; // Context export interface AuthContextType { authenticated: boolean; - login: (username: string, password: string) => Promise; + login: (type: string, username: string, password: string) => Promise; logout: () => void; token?: string; } @@ -36,8 +36,7 @@ function AuthProvider({ setAuthenticated(true); }; - const login = async (identity: string, secret: string) => { - const type = "password"; + const login = async (type: string, identity: string, secret: string) => { const response = await getToken({ payload: { type, identity, secret } }); handleTokenUpdate(response); }; diff --git a/frontend/src/modals/ChangePasswordModal.tsx b/frontend/src/modals/ChangePasswordModal.tsx index e6a2e853..1bdb1a13 100644 --- a/frontend/src/modals/ChangePasswordModal.tsx +++ b/frontend/src/modals/ChangePasswordModal.tsx @@ -5,16 +5,16 @@ import { FormLabel, Input, Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalCloseButton, ModalBody, + ModalCloseButton, + ModalContent, ModalFooter, + ModalHeader, + ModalOverlay, Stack, useToast, } from "@chakra-ui/react"; -import { Formik, Form, Field } from "formik"; +import { Field, Form, Formik } from "formik"; import { setAuth } from "src/api/npm"; import { PrettyButton } from "src/components"; @@ -43,7 +43,7 @@ function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProps) { try { await setAuth("me", { - type: "password", + type: "local", secret: payload.password, currentSecret: payload.current, }); diff --git a/frontend/src/modals/SetPasswordModal.tsx b/frontend/src/modals/SetPasswordModal.tsx index 0c17c5f9..76c037a6 100644 --- a/frontend/src/modals/SetPasswordModal.tsx +++ b/frontend/src/modals/SetPasswordModal.tsx @@ -5,16 +5,16 @@ import { FormLabel, Input, Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalCloseButton, ModalBody, + ModalCloseButton, + ModalContent, ModalFooter, + ModalHeader, + ModalOverlay, Stack, useToast, } from "@chakra-ui/react"; -import { Formik, Form, Field } from "formik"; +import { Field, Form, Formik } from "formik"; import { setAuth } from "src/api/npm"; import { PrettyButton } from "src/components"; @@ -44,7 +44,7 @@ function SetPasswordModal({ userId, isOpen, onClose }: SetPasswordModalProps) { try { await setAuth(userId, { - type: "password", + type: "local", secret: payload.password, }); onClose(); diff --git a/frontend/src/modals/UserCreateModal.tsx b/frontend/src/modals/UserCreateModal.tsx index 93cf4da9..215a4f73 100644 --- a/frontend/src/modals/UserCreateModal.tsx +++ b/frontend/src/modals/UserCreateModal.tsx @@ -7,22 +7,22 @@ import { FormLabel, Input, Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalCloseButton, ModalBody, + ModalCloseButton, + ModalContent, ModalFooter, + ModalHeader, + ModalOverlay, Stack, Tab, - Tabs, TabList, TabPanel, TabPanels, + Tabs, useToast, } from "@chakra-ui/react"; import { useQueryClient } from "@tanstack/react-query"; -import { Formik, Form, Field } from "formik"; +import { Field, Form, Formik } from "formik"; import { createUser } from "src/api/npm"; import { @@ -56,7 +56,7 @@ function UserCreateModal({ isOpen, onClose }: UserCreateModalProps) { ...{ isDisabled: false, auth: { - type: "password", + type: "local", secret: values.password, }, capabilities, diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index 26d4f1fc..c546195c 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef } from "react"; import { + Box, Center, Flex, - Box, FormControl, FormErrorMessage, FormLabel, @@ -12,7 +12,7 @@ import { useColorModeValue, useToast, } from "@chakra-ui/react"; -import { Formik, Form, Field } from "formik"; +import { Field, Form, Formik } from "formik"; import { LocalePicker, PrettyButton, ThemeSwitcher } from "src/components"; import { useAuthState } from "src/context"; @@ -40,7 +40,7 @@ function Login() { }; try { - await login(values.email, values.password); + await login("local", values.email, values.password); } catch (err) { if (err instanceof Error) { showErr(err.message); diff --git a/frontend/src/pages/Setup/index.tsx b/frontend/src/pages/Setup/index.tsx index f509fcfe..ceae4a90 100644 --- a/frontend/src/pages/Setup/index.tsx +++ b/frontend/src/pages/Setup/index.tsx @@ -7,14 +7,14 @@ import { FormControl, FormErrorMessage, FormLabel, - Stack, Heading, Input, + Stack, useColorModeValue, useToast, } from "@chakra-ui/react"; import { useQueryClient } from "@tanstack/react-query"; -import { Formik, Form, Field } from "formik"; +import { Field, Form, Formik } from "formik"; import { createUser } from "src/api/npm"; // import logo from "src/assets/logo-256.png"; @@ -42,7 +42,7 @@ function Setup() { ...{ isDisabled: false, auth: { - type: "password", + type: "local", secret: values.password, }, capabilities: ["full-admin"], @@ -65,7 +65,7 @@ function Setup() { const response = await createUser(payload); if (response && typeof response.id !== "undefined" && response.id) { try { - await login(response.email, password); + await login("local", response.email, password); // Trigger a Health change await queryClient.refetchQueries({ queryKey: ["health"] }); // window.location.reload();