From 2b5c9ae7a8afd95248f47e350e752bb746eb58f6 Mon Sep 17 00:00:00 2001 From: konrad Date: Sat, 21 Nov 2020 16:38:58 +0000 Subject: [PATCH] Authentication with OpenID Connect providers (#713) Add config docs Lint Move provider-related stuff to separate file Refactor getting auth providers Fix tests Fix user tests Fix openid tests Add swagger docs Fix lint Fix lint issues Fix checking if the user already exists Make sure to create a new namespace for new users Docs Add tests for openid Remove unnessecary err check Consistently return nil users if creating a new user failed Move sending confirmation email to separate function Better variable names Move checks to separate functions Refactor creating user into seperate file Fix creating new local users Test creating new users from different issuers Generate a random username right away if no preferred username has been given Add todo Cache openid providers Add getting int clientids Fix migration Move creating tokens to auth package Add getting or creating a third party user Add parsing claims Add retreiving auth tokens Add token callback from openid package Add check for provider key Add routes Start adding openid auth handler Add config for openid auth Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/713 Co-Authored-By: konrad Co-Committed-By: konrad --- config.yml.sample | 29 +++ docs/content/doc/setup/config.md | 24 +++ go.mod | 5 + go.sum | 8 + pkg/cmd/user.go | 8 +- pkg/config/config.go | 18 ++ pkg/db/fixtures/users.yml | 22 ++ pkg/integrations/integrations.go | 6 +- pkg/migration/20201025195822.go | 50 +++++ pkg/models/label_task_test.go | 1 + pkg/models/label_test.go | 4 + pkg/models/list_users_test.go | 2 + pkg/models/namespace.go | 10 + pkg/models/namespace_users_test.go | 2 + pkg/models/task_collection_test.go | 3 + pkg/models/users_list_test.go | 13 ++ pkg/{routes/api/v1 => modules/auth}/auth.go | 17 +- pkg/modules/auth/openid/main_test.go | 34 +++ pkg/modules/auth/openid/openid.go | 206 +++++++++++++++++++ pkg/modules/auth/openid/openid_test.go | 75 +++++++ pkg/modules/auth/openid/providers.go | 127 ++++++++++++ pkg/modules/background/handler/background.go | 6 +- pkg/routes/api/v1/info.go | 33 +++ pkg/routes/api/v1/link_sharing_auth.go | 9 +- pkg/routes/api/v1/login.go | 30 +-- pkg/routes/api/v1/task_attachment.go | 5 +- pkg/routes/api/v1/user_list.go | 3 +- pkg/routes/api/v1/user_register.go | 3 +- pkg/routes/metrics.go | 4 +- pkg/routes/rate_limit.go | 4 +- pkg/routes/routes.go | 22 +- pkg/swagger/docs.go | 141 ++++++++++++- pkg/swagger/swagger.json | 141 ++++++++++++- pkg/swagger/swagger.yaml | 92 ++++++++- pkg/user/user.go | 107 +--------- pkg/user/user_create.go | 157 ++++++++++++++ pkg/user/user_test.go | 22 +- 37 files changed, 1265 insertions(+), 178 deletions(-) create mode 100644 pkg/migration/20201025195822.go rename pkg/{routes/api/v1 => modules/auth}/auth.go (88%) create mode 100644 pkg/modules/auth/openid/main_test.go create mode 100644 pkg/modules/auth/openid/openid.go create mode 100644 pkg/modules/auth/openid/openid_test.go create mode 100644 pkg/modules/auth/openid/providers.go create mode 100644 pkg/user/user_create.go diff --git a/config.yml.sample b/config.yml.sample index 6f69f81bef..e13576f21a 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -215,3 +215,32 @@ legal: keyvalue: # The type of the storage backend. Can be either "memory" or "redis". If "redis" is chosen it needs to be configured seperately. type: "memory" + +auth: + # Local authentication will let users log in and register (if enabled) through the db. + # This is the default auth mechanism and does not require any additional configuration. + local: + # Enable or disable local authentication + enabled: true + # OpenID configuration will allow users to authenticate through a third-party OpenID Connect compatible provider.
+ # The provider needs to support the `openid`, `profile` and `email` scopes.
+ # **Note:** The frontend expects to be redirected after authentication by the third party + # to /auth/openid/. Please make sure to configure the redirect url with your third party + # auth service accordingy if you're using the default vikunja frontend. + # Take a look at the [default config file](https://kolaente.dev/vikunja/api/src/branch/master/config.yml.sample) for more information about how to configure openid authentication. + openid: + # Enable or disable OpenID Connect authentication + enabled: false + # The url to redirect clients to. Defaults to the configured frontend url. If you're using Vikunja with the official + # frontend, you don't need to change this value. + redirecturl: + # A list of enabled providers + providers: + # The name of the provider as it will appear in the frontend. + - name: + # The auth url to send users to if they want to authenticate using OpenID Connect. + authurl: + # The client ID used to authenticate Vikunja at the OpenID Connect provider. + clientid: + # The client secret used to authenticate Vikunja at the OpenID Connect provider. + clientsecret: diff --git a/docs/content/doc/setup/config.md b/docs/content/doc/setup/config.md index 619867b551..6ee7779d75 100644 --- a/docs/content/doc/setup/config.md +++ b/docs/content/doc/setup/config.md @@ -563,3 +563,27 @@ The type of the storage backend. Can be either "memory" or "redis". If "redis" i Default: `memory` +--- + +## auth + + + +### local + +Local authentication will let users log in and register (if enabled) through the db. +This is the default auth mechanism and does not require any additional configuration. + +Default: `` + +### openid + +OpenID configuration will allow users to authenticate through a third-party OpenID Connect compatible provider.
+The provider needs to support the `openid`, `profile` and `email` scopes.
+**Note:** The frontend expects to be redirected after authentication by the third party +to /auth/openid/. Please make sure to configure the redirect url with your third party +auth service accordingy if you're using the default vikunja frontend. +Take a look at the [default config file](https://kolaente.dev/vikunja/api/src/branch/master/config.yml.sample) for more information about how to configure openid authentication. + +Default: `` + diff --git a/go.mod b/go.mod index 442cde9a6a..fc52a5b148 100644 --- a/go.mod +++ b/go.mod @@ -26,10 +26,12 @@ require ( github.com/beevik/etree v1.1.0 // indirect github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2 github.com/client9/misspell v0.3.4 + github.com/coreos/go-oidc v2.2.1+incompatible github.com/cweill/gotests v1.5.3 github.com/d4l3k/messagediff v1.2.1 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/disintegration/imaging v1.6.2 + github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 github.com/fzipp/gocyclo v0.3.1 github.com/gabriel-vasile/mimetype v1.1.2 github.com/getsentry/sentry-go v0.8.0 @@ -59,6 +61,7 @@ require ( github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/pelletier/go-toml v1.8.0 // indirect github.com/pquerna/otp v1.3.0 + github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e // indirect github.com/prometheus/client_golang v1.8.0 github.com/samedi/caldav-go v3.0.0+incompatible github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 @@ -75,6 +78,7 @@ require ( golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 golang.org/x/lint v0.0.0-20200302205851-738671d3881b golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0 // indirect + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 golang.org/x/tools v0.0.0-20201017001424-6003fad69a88 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect @@ -82,6 +86,7 @@ require ( gopkg.in/d4l3k/messagediff.v1 v1.2.1 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/ini.v1 v1.57.0 // indirect + gopkg.in/square/go-jose.v2 v2.5.1 // indirect gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c honnef.co/go/tools v0.0.1-2020.1.5 src.techknowlogick.com/xgo v1.1.1-0.20200811225412-bff6512e7c9c diff --git a/go.sum b/go.sum index 42c2d03242..950727c54f 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcju github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= +github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= @@ -143,6 +145,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 h1:90Ly+6UfUypEF6vvvW5rQIv9opIL8CbmW9FT20LDQoY= +github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0/go.mod h1:V+Qd57rJe8gd4eiGzZyg4h54VLHmYVVw54iMnlAMrF8= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= @@ -642,6 +646,8 @@ github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZ 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e h1:BLqxdwZ6j771IpSCRx7s/GJjXHUE00Hmu7/YegCGdzA= +github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e/go.mod h1:hoLfEwdY11HjRfKFH6KqnPsfxlo3BP6bJehpDv8t6sQ= github.com/pquerna/otp v1.2.0 h1:/A3+Jn+cagqayeR3iHs/L62m5ue7710D35zl1zJ1kok= github.com/pquerna/otp v1.2.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= @@ -1140,6 +1146,8 @@ gopkg.in/jcmturner/gokrb5.v7 v7.5.0/go.mod h1:l8VISx+WGYp+Fp7KRbsiUuXTTOnxIc3Tuv gopkg.in/jcmturner/rpc.v1 v1.1.0/go.mod h1:YIdkC4XfD6GXbzje11McwsDuOlZQSb9W4vfLvuNnlv8= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= diff --git a/pkg/cmd/user.go b/pkg/cmd/user.go index 0c2c13a44a..b1f37bd33d 100644 --- a/pkg/cmd/user.go +++ b/pkg/cmd/user.go @@ -26,6 +26,7 @@ import ( "code.vikunja.io/api/pkg/initialize" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" @@ -157,11 +158,16 @@ var userCreateCmd = &cobra.Command{ Email: userFlagEmail, Password: getPasswordFromFlagOrInput(), } - _, err := user.CreateUser(u) + newUser, err := user.CreateUser(u) if err != nil { log.Fatalf("Error creating new user: %s", err) } + err = models.CreateNewNamespaceForUser(newUser) + if err != nil { + log.Fatalf("Error creating new namespace for user: %s", err) + } + fmt.Printf("\nUser was created successfully.\n") }, } diff --git a/pkg/config/config.go b/pkg/config/config.go index f17f7b26de..6dfd00442c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -52,6 +52,11 @@ const ( ServiceEnableTotp Key = `service.enabletotp` ServiceSentryDsn Key = `service.sentrydsn` + AuthLocalEnabled Key = `auth.local.enabled` + AuthOpenIDEnabled Key = `auth.openid.enabled` + AuthOpenIDRedirectURL Key = `auth.openid.redirecturl` + AuthOpenIDProviders Key = `auth.openid.providers` + LegalImprintURL Key = `legal.imprinturl` LegalPrivacyURL Key = `legal.privacyurl` @@ -158,6 +163,11 @@ func (k Key) GetStringSlice() []string { return viper.GetStringSlice(string(k)) } +// Get returns the raw value from a config option +func (k Key) Get() interface{} { + return viper.Get(string(k)) +} + var timezone *time.Location // GetTimeZone returns the time zone configured for vikunja @@ -216,6 +226,10 @@ func InitDefaultConfig() { ServiceEnableTaskComments.setDefault(true) ServiceEnableTotp.setDefault(true) + // Auth + AuthLocalEnabled.setDefault(true) + AuthOpenIDEnabled.setDefault(false) + // Database DatabaseType.setDefault("sqlite") DatabaseHost.setDefault("localhost") @@ -322,6 +336,10 @@ func InitConfig() { RateLimitStore.Set(KeyvalueType.GetString()) } + if AuthOpenIDRedirectURL.GetString() == "" { + AuthOpenIDRedirectURL.Set(ServiceFrontendurl.GetString() + "auth/openid/") + } + log.Printf("Using config file: %s", viper.ConfigFileUsed()) } diff --git a/pkg/db/fixtures/users.yml b/pkg/db/fixtures/users.yml index 268a3fa112..9c152be640 100644 --- a/pkg/db/fixtures/users.yml +++ b/pkg/db/fixtures/users.yml @@ -4,6 +4,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user1@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -11,6 +12,7 @@ username: 'user2' password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user2@example.com' + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -19,6 +21,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user3@example.com' password_reset_token: passwordresettesttoken + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -27,6 +30,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user4@example.com' email_confirm_token: tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -36,6 +40,7 @@ email: 'user5@example.com' email_confirm_token: tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael is_active: false + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 # This use is used to create a whole bunch of lists which are then shared directly with a user @@ -44,6 +49,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user6@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 7 @@ -51,6 +57,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user7@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 8 @@ -58,6 +65,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user8@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 9 @@ -65,6 +73,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user9@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 10 @@ -72,6 +81,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user10@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 11 @@ -79,6 +89,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user11@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 12 @@ -86,6 +97,7 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user12@example.com' is_active: true + issuer: local updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - id: 13 @@ -93,5 +105,15 @@ password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 email: 'user14@example.com' is_active: true + issuer: local + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 +- id: 14 + username: 'user14' + password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 + email: 'user15@some.service.com' + is_active: true + issuer: 'https://some.service.com' + subject: '12345' updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 diff --git a/pkg/integrations/integrations.go b/pkg/integrations/integrations.go index a625c6f90a..1928a00709 100644 --- a/pkg/integrations/integrations.go +++ b/pkg/integrations/integrations.go @@ -28,8 +28,8 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/routes" - v1 "code.vikunja.io/api/pkg/routes/api/v1" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "code.vikunja.io/web/handler" @@ -119,7 +119,7 @@ func newTestRequest(t *testing.T, method string, handler func(ctx echo.Context) func addUserTokenToContext(t *testing.T, user *user.User, c echo.Context) { // Get the token as a string - token, err := v1.NewUserJWTAuthtoken(user) + token, err := auth.NewUserJWTAuthtoken(user) assert.NoError(t, err) // We send the string token through the parsing function to get a valid jwt.Token tken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { @@ -131,7 +131,7 @@ func addUserTokenToContext(t *testing.T, user *user.User, c echo.Context) { func addLinkShareTokenToContext(t *testing.T, share *models.LinkSharing, c echo.Context) { // Get the token as a string - token, err := v1.NewLinkShareJWTAuthtoken(share) + token, err := auth.NewLinkShareJWTAuthtoken(share) assert.NoError(t, err) // We send the string token through the parsing function to get a valid jwt.Token tken, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { diff --git a/pkg/migration/20201025195822.go b/pkg/migration/20201025195822.go new file mode 100644 index 0000000000..73d5c62ecf --- /dev/null +++ b/pkg/migration/20201025195822.go @@ -0,0 +1,50 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type user20201025195822 struct { + Issuer string `xorm:"text null" json:"-"` + Subject string `xorm:"text null" json:"-"` +} + +func (user20201025195822) TableName() string { + return "users" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20201025195822", + Description: "", + Migrate: func(tx *xorm.Engine) error { + err := tx.Sync2(user20201025195822{}) + if err != nil { + return err + } + + _, err = tx.Cols("issuer").Update(&user20201025195822{Issuer: "local"}) + return err + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/label_task_test.go b/pkg/models/label_task_test.go index 3bcdf50eeb..a7f26558ab 100644 --- a/pkg/models/label_task_test.go +++ b/pkg/models/label_task_test.go @@ -56,6 +56,7 @@ func TestLabelTask_ReadAll(t *testing.T) { ID: 2, Username: "user2", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/label_test.go b/pkg/models/label_test.go index dc96b65e1a..add57c28b3 100644 --- a/pkg/models/label_test.go +++ b/pkg/models/label_test.go @@ -52,6 +52,7 @@ func TestLabel_ReadAll(t *testing.T) { Username: "user1", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -99,6 +100,7 @@ func TestLabel_ReadAll(t *testing.T) { ID: 2, Username: "user2", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, }, @@ -159,6 +161,7 @@ func TestLabel_ReadOne(t *testing.T) { Username: "user1", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -217,6 +220,7 @@ func TestLabel_ReadOne(t *testing.T) { ID: 2, Username: "user2", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/list_users_test.go b/pkg/models/list_users_test.go index a945691111..2d29efaedf 100644 --- a/pkg/models/list_users_test.go +++ b/pkg/models/list_users_test.go @@ -176,6 +176,7 @@ func TestListUser_ReadAll(t *testing.T) { Username: "user1", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, }, @@ -186,6 +187,7 @@ func TestListUser_ReadAll(t *testing.T) { ID: 2, Username: "user2", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/namespace.go b/pkg/models/namespace.go index e3b77b8efd..c6acd65742 100644 --- a/pkg/models/namespace.go +++ b/pkg/models/namespace.go @@ -445,6 +445,16 @@ func (n *Namespace) Create(a web.Auth) (err error) { return } +// CreateNewNamespaceForUser creates a new namespace for a user. To prevent import cycles, we can't do that +// directly in the user.Create function. +func CreateNewNamespaceForUser(user *user.User) (err error) { + newN := &Namespace{ + Title: user.Username, + Description: user.Username + "'s namespace.", + } + return newN.Create(user) +} + // Delete deletes a namespace // @Summary Deletes a namespace // @Description Delets a namespace diff --git a/pkg/models/namespace_users_test.go b/pkg/models/namespace_users_test.go index 69dc80919b..554bcae2af 100644 --- a/pkg/models/namespace_users_test.go +++ b/pkg/models/namespace_users_test.go @@ -175,6 +175,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) { Username: "user1", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, }, @@ -185,6 +186,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) { ID: 2, Username: "user2", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, }, diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index af999503a4..a333dfdc97 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -35,6 +35,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { Username: "user1", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -42,6 +43,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { ID: 2, Username: "user2", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -49,6 +51,7 @@ func TestTaskCollection_ReadAll(t *testing.T) { ID: 6, Username: "user6", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", IsActive: true, Created: testCreatedTime, Updated: testUpdatedTime, diff --git a/pkg/models/users_list_test.go b/pkg/models/users_list_test.go index 955a7261f5..264009eccd 100644 --- a/pkg/models/users_list_test.go +++ b/pkg/models/users_list_test.go @@ -31,6 +31,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user1", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -38,6 +39,7 @@ func TestListUsersFromList(t *testing.T) { ID: 2, Username: "user2", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -46,6 +48,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user3", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", PasswordResetToken: "passwordresettesttoken", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -55,6 +58,7 @@ func TestListUsersFromList(t *testing.T) { Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: false, EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -64,6 +68,7 @@ func TestListUsersFromList(t *testing.T) { Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: false, EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -72,6 +77,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user6", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -80,6 +86,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user7", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -88,6 +95,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user8", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -96,6 +104,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user9", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -104,6 +113,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user10", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -112,6 +122,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user11", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -120,6 +131,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user12", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } @@ -128,6 +140,7 @@ func TestListUsersFromList(t *testing.T) { Username: "user13", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", IsActive: true, + Issuer: "local", Created: testCreatedTime, Updated: testUpdatedTime, } diff --git a/pkg/routes/api/v1/auth.go b/pkg/modules/auth/auth.go similarity index 88% rename from pkg/routes/api/v1/auth.go rename to pkg/modules/auth/auth.go index 2367488df1..4729e0e3d8 100644 --- a/pkg/routes/api/v1/auth.go +++ b/pkg/modules/auth/auth.go @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -package v1 +package auth import ( "net/http" @@ -35,6 +35,21 @@ const ( AuthTypeLinkShare ) +// Token represents an authentification token +type Token struct { + Token string `json:"token"` +} + +// NewUserAuthTokenResponse creates a new user auth token response from a user object. +func NewUserAuthTokenResponse(u *user.User, c echo.Context) error { + t, err := NewUserJWTAuthtoken(u) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, Token{Token: t}) +} + // NewUserJWTAuthtoken generates and signes a new jwt token for a user. This is a global function to be able to call it from integration tests. func NewUserJWTAuthtoken(user *user.User) (token string, err error) { t := jwt.New(jwt.SigningMethodHS256) diff --git a/pkg/modules/auth/openid/main_test.go b/pkg/modules/auth/openid/main_test.go new file mode 100644 index 0000000000..17f1f402f2 --- /dev/null +++ b/pkg/modules/auth/openid/main_test.go @@ -0,0 +1,34 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package openid + +import ( + "os" + "testing" + + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" +) + +// TestMain is the main test function used to bootstrap the test env +func TestMain(m *testing.M) { + user.InitTests() + files.InitTests() + models.SetupTests() + os.Exit(m.Run()) +} diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go new file mode 100644 index 0000000000..8a9b77d9b4 --- /dev/null +++ b/pkg/modules/auth/openid/openid.go @@ -0,0 +1,206 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package openid + +import ( + "context" + "encoding/json" + "math/rand" + "net/http" + "time" + + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + "github.com/coreos/go-oidc" + petname "github.com/dustinkirkland/golang-petname" + "github.com/labstack/echo/v4" + "golang.org/x/oauth2" +) + +// Callback contains the callback after an auth request was made and redirected +type Callback struct { + Code string `query:"code" json:"code"` + Scope string `query:"scop" json:"scope"` +} + +// Provider is the structure of an OpenID Connect provider +type Provider struct { + Name string `json:"name"` + Key string `json:"key"` + AuthURL string `json:"auth_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"-"` + OpenIDProvider *oidc.Provider `json:"-"` + Oauth2Config *oauth2.Config `json:"-"` +} + +type claims struct { + Email string `json:"email"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` +} + +func init() { + rand.Seed(time.Now().UTC().UnixNano()) +} + +// HandleCallback handles the auth request callback after redirecting from the provider with an auth code +// @Summary Authenticate a user with OpenID Connect +// @Description After a redirect from the OpenID Connect provider to the frontend has been made with the authentication `code`, this endpoint can be used to obtain a jwt token for that user and thus log them in. +// @tags auth +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param callback body openid.Callback true "The openid callback" +// @Param provider path int true "The OpenID Connect provider key as returned by the /info endpoint" +// @Success 200 {object} auth.Token +// @Failure 500 {object} models.Message "Internal error" +// @Router /auth/openid/{provider}/callback [post] +func HandleCallback(c echo.Context) error { + cb := &Callback{} + if err := c.Bind(cb); err != nil { + return c.JSON(http.StatusBadRequest, models.Message{Message: "Bad data"}) + } + + // Check if the provider exists + providerKey := c.Param("provider") + provider, err := GetProvider(providerKey) + if err != nil { + log.Error(err) + return err + } + if provider == nil { + return c.JSON(http.StatusBadRequest, models.Message{Message: "Provider does not exist"}) + } + + // Parse the access & ID token + oauth2Token, err := provider.Oauth2Config.Exchange(context.Background(), cb.Code) + if err != nil { + if rerr, is := err.(*oauth2.RetrieveError); is { + log.Error(err) + + details := make(map[string]interface{}) + if err := json.Unmarshal(rerr.Body, &details); err != nil { + return err + } + + return c.JSON(http.StatusBadRequest, map[string]interface{}{ + "message": "Could not authenticate against third party.", + "details": details, + }) + } + + return err + } + + // Extract the ID Token from OAuth2 token. + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return c.JSON(http.StatusBadRequest, models.Message{Message: "Missing token"}) + } + + verifier := provider.OpenIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID}) + + // Parse and verify ID Token payload. + idToken, err := verifier.Verify(context.Background(), rawIDToken) + if err != nil { + return err + } + + // Extract custom claims + cl := &claims{} + err = idToken.Claims(cl) + if err != nil { + return err + } + + // Check if we have seen this user before + u, err := getOrCreateUser(cl, idToken.Issuer, idToken.Subject) + if err != nil { + return err + } + + // Create token + return auth.NewUserAuthTokenResponse(u, c) +} + +func getOrCreateUser(cl *claims, issuer, subject string) (u *user.User, err error) { + // Check if the user exists for that issuer and subject + u, err = user.GetUserWithEmail(&user.User{ + Issuer: issuer, + Subject: subject, + }) + if err != nil && !user.IsErrUserDoesNotExist(err) { + return nil, err + } + + // If no user exists, create one with the preferred username if it is not already taken + if user.IsErrUserDoesNotExist(err) { + uu := &user.User{ + Username: cl.PreferredUsername, + Email: cl.Email, + IsActive: true, + Issuer: issuer, + Subject: subject, + } + + // Check if we actually have a preferred username and generate a random one right away if we don't + if uu.Username == "" { + uu.Username = petname.Generate(3, "-") + } + + u, err = user.CreateUser(uu) + if err != nil && !user.IsErrUsernameExists(err) { + return nil, err + } + + // If their preferred username is already taken, create some random one from the email and subject + if user.IsErrUsernameExists(err) { + uu.Username = petname.Generate(3, "-") + u, err = user.CreateUser(uu) + if err != nil { + return nil, err + } + } + + // And create its namespace + err = models.CreateNewNamespaceForUser(u) + if err != nil { + return nil, err + } + + return + } + + // If it exists, check if the email address changed and change it if not + if cl.Email != u.Email { + u.Email = cl.Email + u, err = user.UpdateUser(&user.User{ + ID: u.ID, + Email: cl.Email, + Issuer: issuer, + Subject: subject, + }) + if err != nil { + return nil, err + } + } + + return +} diff --git a/pkg/modules/auth/openid/openid_test.go b/pkg/modules/auth/openid/openid_test.go new file mode 100644 index 0000000000..4e29727bb6 --- /dev/null +++ b/pkg/modules/auth/openid/openid_test.go @@ -0,0 +1,75 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package openid + +import ( + "testing" + + "code.vikunja.io/api/pkg/db" + "github.com/stretchr/testify/assert" +) + +func TestGetOrCreateUser(t *testing.T) { + t.Run("new user", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + cl := &claims{ + Email: "test@example.com", + PreferredUsername: "someUserWhoDoesNotExistYet", + } + u, err := getOrCreateUser(cl, "https://some.issuer", "12345") + assert.NoError(t, err) + db.AssertExists(t, "users", map[string]interface{}{ + "id": u.ID, + "email": cl.Email, + "username": "someUserWhoDoesNotExistYet", + }, false) + }) + t.Run("new user, no username provided", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + cl := &claims{ + Email: "test@example.com", + PreferredUsername: "", + } + u, err := getOrCreateUser(cl, "https://some.issuer", "12345") + assert.NoError(t, err) + assert.NotEmpty(t, u.Username) + db.AssertExists(t, "users", map[string]interface{}{ + "id": u.ID, + "email": cl.Email, + }, false) + }) + t.Run("new user, no email address", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + cl := &claims{ + Email: "", + } + _, err := getOrCreateUser(cl, "https://some.issuer", "12345") + assert.Error(t, err) + }) + t.Run("existing user, different email address", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + cl := &claims{ + Email: "other-email-address@some.service.com", + } + u, err := getOrCreateUser(cl, "https://some.service.com", "12345") + assert.NoError(t, err) + db.AssertExists(t, "users", map[string]interface{}{ + "id": u.ID, + "email": cl.Email, + }, false) + }) +} diff --git a/pkg/modules/auth/openid/providers.go b/pkg/modules/auth/openid/providers.go new file mode 100644 index 0000000000..7d4133ba88 --- /dev/null +++ b/pkg/modules/auth/openid/providers.go @@ -0,0 +1,127 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package openid + +import ( + "context" + "regexp" + "strconv" + "strings" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/keyvalue" + kerr "code.vikunja.io/api/pkg/modules/keyvalue/error" + "github.com/coreos/go-oidc" + "golang.org/x/oauth2" +) + +// GetAllProviders returns all configured providers +func GetAllProviders() (providers []*Provider, err error) { + ps, err := keyvalue.Get("openid_providers") + if err != nil && kerr.IsErrValueNotFoundForKey(err) { + rawProvider := config.AuthOpenIDProviders.Get().([]interface{}) + + for _, p := range rawProvider { + pi := p.(map[interface{}]interface{}) + + provider, err := getProviderFromMap(pi) + if err != nil { + return nil, err + } + + providers = append(providers, provider) + + k := getKeyFromName(pi["name"].(string)) + err = keyvalue.Put("openid_provider_"+k, provider) + if err != nil { + return nil, err + } + } + err = keyvalue.Put("openid_providers", providers) + } + + if ps != nil { + return ps.([]*Provider), nil + } + + return +} + +// GetProvider retrieves a provider from keyvalue +func GetProvider(key string) (provider *Provider, err error) { + var p interface{} + p, err = keyvalue.Get("openid_provider_" + key) + if err != nil && kerr.IsErrValueNotFoundForKey(err) { + _, err = GetAllProviders() // This will put all providers in cache + if err != nil { + return nil, err + } + + p, err = keyvalue.Get("openid_provider_" + key) + } + + if p != nil { + return p.(*Provider), nil + } + + return nil, err +} + +func getKeyFromName(name string) string { + reg := regexp.MustCompile("[^a-z0-9]+") + return reg.ReplaceAllString(strings.ToLower(name), "") +} + +func getProviderFromMap(pi map[interface{}]interface{}) (*Provider, error) { + k := getKeyFromName(pi["name"].(string)) + + provider := &Provider{ + Name: pi["name"].(string), + Key: k, + AuthURL: pi["authurl"].(string), + ClientSecret: pi["clientsecret"].(string), + } + + cl, is := pi["clientid"].(int) + if is { + provider.ClientID = strconv.Itoa(cl) + } else { + provider.ClientID = pi["clientid"].(string) + } + + var err error + provider.OpenIDProvider, err = oidc.NewProvider(context.Background(), provider.AuthURL) + if err != nil { + return nil, err + } + + provider.Oauth2Config = &oauth2.Config{ + ClientID: provider.ClientID, + ClientSecret: provider.ClientSecret, + RedirectURL: config.AuthOpenIDRedirectURL.GetString() + k, + + // Discovery returns the OAuth2 endpoints. + Endpoint: provider.OpenIDProvider.Endpoint(), + + // "openid" is a required scope for OpenID Connect flows. + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL + + return provider, nil +} diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index 2fecef566d..2c1921c4ea 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -25,9 +25,9 @@ import ( "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" + auth2 "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/background" "code.vikunja.io/api/pkg/modules/background/unsplash" - v1 "code.vikunja.io/api/pkg/routes/api/v1" "code.vikunja.io/web" "code.vikunja.io/web/handler" "github.com/gabriel-vasile/mimetype" @@ -69,7 +69,7 @@ func (bp *BackgroundProvider) SearchBackgrounds(c echo.Context) error { // This function does all kinds of preparations for setting and uploading a background func (bp *BackgroundProvider) setBackgroundPreparations(c echo.Context) (list *models.List, auth web.Auth, err error) { - auth, err = v1.GetAuthFromClaims(c) + auth, err = auth2.GetAuthFromClaims(c) if err != nil { return nil, nil, echo.NewHTTPError(http.StatusBadRequest, "Invalid auth token: "+err.Error()) } @@ -180,7 +180,7 @@ func (bp *BackgroundProvider) UploadBackground(c echo.Context) error { // @Router /lists/{id}/background [get] func GetListBackground(c echo.Context) error { - auth, err := v1.GetAuthFromClaims(c) + auth, err := auth2.GetAuthFromClaims(c) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid auth token: "+err.Error()) } diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 646d43f5c8..faa06db5b8 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -20,6 +20,7 @@ import ( "net/http" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/auth/openid" "code.vikunja.io/api/pkg/modules/migration/todoist" "code.vikunja.io/api/pkg/modules/migration/wunderlist" "code.vikunja.io/api/pkg/version" @@ -39,6 +40,22 @@ type vikunjaInfos struct { TotpEnabled bool `json:"totp_enabled"` Legal legalInfo `json:"legal"` CaldavEnabled bool `json:"caldav_enabled"` + AuthInfo authInfo `json:"auth"` +} + +type authInfo struct { + Local localAuthInfo `json:"local"` + OpenIDConnect openIDAuthInfo `json:"openid_connect"` +} + +type localAuthInfo struct { + Enabled bool `json:"enabled"` +} + +type openIDAuthInfo struct { + Enabled bool `json:"enabled"` + RedirectURL string `json:"redirect_url"` + Providers []*openid.Provider `json:"providers"` } type legalInfo struct { @@ -68,8 +85,24 @@ func Info(c echo.Context) error { ImprintURL: config.LegalImprintURL.GetString(), PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), }, + AuthInfo: authInfo{ + Local: localAuthInfo{ + Enabled: config.AuthLocalEnabled.GetBool(), + }, + OpenIDConnect: openIDAuthInfo{ + Enabled: config.AuthOpenIDEnabled.GetBool(), + RedirectURL: config.AuthOpenIDRedirectURL.GetString(), + }, + }, } + providers, err := openid.GetAllProviders() + if err != nil { + return err + } + + info.AuthInfo.OpenIDConnect.Providers = providers + // Migrators if config.MigrationWunderlistEnable.GetBool() { m := &wunderlist.Migration{} diff --git a/pkg/routes/api/v1/link_sharing_auth.go b/pkg/routes/api/v1/link_sharing_auth.go index ba62c855ff..0040cfecb6 100644 --- a/pkg/routes/api/v1/link_sharing_auth.go +++ b/pkg/routes/api/v1/link_sharing_auth.go @@ -20,13 +20,14 @@ import ( "net/http" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/web/handler" "github.com/labstack/echo/v4" ) // LinkShareToken represents a link share auth token with extra infos about the actual link share type LinkShareToken struct { - Token + auth.Token *models.LinkSharing ListID int64 `json:"list_id"` } @@ -38,7 +39,7 @@ type LinkShareToken struct { // @Accept json // @Produce json // @Param share path string true "The share hash" -// @Success 200 {object} v1.Token "The valid jwt auth token." +// @Success 200 {object} auth.Token "The valid jwt auth token." // @Failure 400 {object} web.HTTPError "Invalid link share object provided." // @Failure 500 {object} models.Message "Internal error" // @Router /shares/{share}/auth [post] @@ -49,13 +50,13 @@ func AuthenticateLinkShare(c echo.Context) error { return handler.HandleHTTPError(err, c) } - t, err := NewLinkShareJWTAuthtoken(share) + t, err := auth.NewLinkShareJWTAuthtoken(share) if err != nil { return handler.HandleHTTPError(err, c) } return c.JSON(http.StatusOK, LinkShareToken{ - Token: Token{Token: t}, + Token: auth.Token{Token: t}, LinkSharing: share, ListID: share.ListID, }) diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index e9733724e2..79ab6ae79e 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -20,17 +20,13 @@ import ( "net/http" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" user2 "code.vikunja.io/api/pkg/user" "code.vikunja.io/web/handler" "github.com/dgrijalva/jwt-go" "github.com/labstack/echo/v4" ) -// Token represents an authentification token -type Token struct { - Token string `json:"token"` -} - // Login is the login handler // @Summary Login // @Description Logs a user in. Returns a JWT-Token to authenticate further requests. @@ -38,7 +34,7 @@ type Token struct { // @Accept json // @Produce json // @Param credentials body user.Login true "The login credentials" -// @Success 200 {object} v1.Token +// @Success 200 {object} auth.Token // @Failure 400 {object} models.Message "Invalid user password model." // @Failure 412 {object} models.Message "Invalid totp passcode." // @Failure 403 {object} models.Message "Invalid username or password." @@ -71,12 +67,7 @@ func Login(c echo.Context) error { } // Create token - t, err := NewUserJWTAuthtoken(user) - if err != nil { - return err - } - - return c.JSON(http.StatusOK, Token{Token: t}) + return auth.NewUserAuthTokenResponse(user, c) } // RenewToken gives a new token to every user with a valid token @@ -86,7 +77,7 @@ func Login(c echo.Context) error { // @tags user // @Accept json // @Produce json -// @Success 200 {object} v1.Token +// @Success 200 {object} auth.Token // @Failure 400 {object} models.Message "Only user token are available for renew." // @Router /user/token [post] func RenewToken(c echo.Context) (err error) { @@ -94,18 +85,18 @@ func RenewToken(c echo.Context) (err error) { jwtinf := c.Get("user").(*jwt.Token) claims := jwtinf.Claims.(jwt.MapClaims) typ := int(claims["type"].(float64)) - if typ == AuthTypeLinkShare { + if typ == auth.AuthTypeLinkShare { share := &models.LinkSharing{} share.ID = int64(claims["id"].(float64)) err := share.ReadOne() if err != nil { return handler.HandleHTTPError(err, c) } - t, err := NewLinkShareJWTAuthtoken(share) + t, err := auth.NewLinkShareJWTAuthtoken(share) if err != nil { return handler.HandleHTTPError(err, c) } - return c.JSON(http.StatusOK, Token{Token: t}) + return c.JSON(http.StatusOK, auth.Token{Token: t}) } user, err := user2.GetUserFromClaims(claims) @@ -114,10 +105,5 @@ func RenewToken(c echo.Context) (err error) { } // Create token - t, err := NewUserJWTAuthtoken(user) - if err != nil { - return err - } - - return c.JSON(http.StatusOK, Token{Token: t}) + return auth.NewUserAuthTokenResponse(user, c) } diff --git a/pkg/routes/api/v1/task_attachment.go b/pkg/routes/api/v1/task_attachment.go index 5a995d1306..1d3cc61f48 100644 --- a/pkg/routes/api/v1/task_attachment.go +++ b/pkg/routes/api/v1/task_attachment.go @@ -20,6 +20,7 @@ import ( "net/http" "code.vikunja.io/api/pkg/models" + auth2 "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/web/handler" "github.com/labstack/echo/v4" ) @@ -46,7 +47,7 @@ func UploadTaskAttachment(c echo.Context) error { } // Rights check - auth, err := GetAuthFromClaims(c) + auth, err := auth2.GetAuthFromClaims(c) if err != nil { return handler.HandleHTTPError(err, c) } @@ -116,7 +117,7 @@ func GetTaskAttachment(c echo.Context) error { } // Rights check - auth, err := GetAuthFromClaims(c) + auth, err := auth2.GetAuthFromClaims(c) if err != nil { return handler.HandleHTTPError(err, c) } diff --git a/pkg/routes/api/v1/user_list.go b/pkg/routes/api/v1/user_list.go index 2b5053fe46..ca925516ee 100644 --- a/pkg/routes/api/v1/user_list.go +++ b/pkg/routes/api/v1/user_list.go @@ -21,6 +21,7 @@ import ( "strconv" "code.vikunja.io/api/pkg/models" + auth2 "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web/handler" "github.com/labstack/echo/v4" @@ -74,7 +75,7 @@ func ListUsersForList(c echo.Context) error { } list := models.List{ID: listID} - auth, err := GetAuthFromClaims(c) + auth, err := auth2.GetAuthFromClaims(c) if err != nil { return handler.HandleHTTPError(err, c) } diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 3130d731da..4c19b20531 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -57,8 +57,7 @@ func RegisterUser(c echo.Context) error { } // Add its namespace - newN := &models.Namespace{Title: newUser.Username, Description: newUser.Username + "'s namespace.", Owner: newUser} - err = newN.Create(newUser) + err = models.CreateNewNamespaceForUser(newUser) if err != nil { return handler.HandleHTTPError(err, c) } diff --git a/pkg/routes/metrics.go b/pkg/routes/metrics.go index 61722f0699..e984feb2dc 100644 --- a/pkg/routes/metrics.go +++ b/pkg/routes/metrics.go @@ -22,7 +22,7 @@ import ( "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" - v1 "code.vikunja.io/api/pkg/routes/api/v1" + auth2 "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v4" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -95,7 +95,7 @@ func setupMetricsMiddleware(a *echo.Group) { // updateActiveUsersFromContext updates the currently active users in redis func updateActiveUsersFromContext(c echo.Context) (err error) { - auth, err := v1.GetAuthFromClaims(c) + auth, err := auth2.GetAuthFromClaims(c) if err != nil { return } diff --git a/pkg/routes/rate_limit.go b/pkg/routes/rate_limit.go index a038081e86..e6d8ff6035 100644 --- a/pkg/routes/rate_limit.go +++ b/pkg/routes/rate_limit.go @@ -24,8 +24,8 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" + auth2 "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/red" - apiv1 "code.vikunja.io/api/pkg/routes/api/v1" "github.com/labstack/echo/v4" "github.com/ulule/limiter/v3" "github.com/ulule/limiter/v3/drivers/store/memory" @@ -41,7 +41,7 @@ func RateLimit(rateLimiter *limiter.Limiter, rateLimitKind string) echo.Middlewa case "ip": rateLimitKey = c.RealIP() case "user": - auth, err := apiv1.GetAuthFromClaims(c) + auth, err := auth2.GetAuthFromClaims(c) if err != nil { log.Errorf("Error getting auth from jwt claims: %v", err) } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index dfdb4844f2..87e00b8dd9 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -53,6 +53,8 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/openid" "code.vikunja.io/api/pkg/modules/background" backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler" "code.vikunja.io/api/pkg/modules/background/unsplash" @@ -165,7 +167,7 @@ func NewEcho() *echo.Echo { // Handler config handler.SetAuthProvider(&web.Auths{ - AuthObject: apiv1.GetAuthFromClaims, + AuthObject: auth.GetAuthFromClaims, }) handler.SetLoggingProvider(log.GetLogger()) handler.SetMaxItemsPerPage(config.ServiceMaxItemsPerPage.GetInt()) @@ -220,12 +222,18 @@ func registerAPIRoutes(a *echo.Group) { // Prometheus endpoint setupMetrics(n) - // User stuff - n.POST("/login", apiv1.Login) - n.POST("/register", apiv1.RegisterUser) - n.POST("/user/password/token", apiv1.UserRequestResetPasswordToken) - n.POST("/user/password/reset", apiv1.UserResetPassword) - n.POST("/user/confirm", apiv1.UserConfirmEmail) + if config.AuthLocalEnabled.GetBool() { + // User stuff + n.POST("/login", apiv1.Login) + n.POST("/register", apiv1.RegisterUser) + n.POST("/user/password/token", apiv1.UserRequestResetPasswordToken) + n.POST("/user/password/reset", apiv1.UserResetPassword) + n.POST("/user/confirm", apiv1.UserConfirmEmail) + } + + if config.AuthOpenIDEnabled.GetBool() { + n.POST("/auth/openid/:provider/callback", openid.HandleCallback) + } // Info endpoint n.GET("/info", apiv1.Info) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index be130778ab..516fd0ec9b 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -32,6 +32,58 @@ var doc = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/auth/openid/{provider}/callback": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "After a redirect from the OpenID Connect provider to the frontend has been made with the authentication ` + "`" + `code` + "`" + `, this endpoint can be used to obtain a jwt token for that user and thus log them in.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Authenticate a user with OpenID Connect", + "parameters": [ + { + "description": "The openid callback", + "name": "callback", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/openid.Callback" + } + }, + { + "type": "integer", + "description": "The OpenID Connect provider key as returned by the /info endpoint", + "name": "provider", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/auth.Token" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/backgrounds/unsplash/image/{image}": { "get": { "security": [ @@ -2426,7 +2478,7 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.Token" + "$ref": "#/definitions/auth.Token" } }, "400": { @@ -3671,7 +3723,7 @@ var doc = `{ "200": { "description": "The valid jwt auth token.", "schema": { - "$ref": "#/definitions/v1.Token" + "$ref": "#/definitions/auth.Token" } }, "400": { @@ -6240,7 +6292,7 @@ var doc = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.Token" + "$ref": "#/definitions/auth.Token" } }, "400": { @@ -6352,6 +6404,14 @@ var doc = `{ } }, "definitions": { + "auth.Token": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, "background.Image": { "type": "object", "properties": { @@ -7430,6 +7490,34 @@ var doc = `{ } } }, + "openid.Callback": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "openid.Provider": { + "type": "object", + "properties": { + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "todoist.Migration": { "type": "object", "properties": { @@ -7577,14 +7665,6 @@ var doc = `{ } } }, - "v1.Token": { - "type": "object", - "properties": { - "token": { - "type": "string" - } - } - }, "v1.UserAvatarProvider": { "type": "object", "properties": { @@ -7604,6 +7684,17 @@ var doc = `{ } } }, + "v1.authInfo": { + "type": "object", + "properties": { + "local": { + "$ref": "#/definitions/v1.localAuthInfo" + }, + "openid_connect": { + "$ref": "#/definitions/v1.openIDAuthInfo" + } + } + }, "v1.legalInfo": { "type": "object", "properties": { @@ -7615,9 +7706,37 @@ var doc = `{ } } }, + "v1.localAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "v1.openIDAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/openid.Provider" + } + }, + "redirect_url": { + "type": "string" + } + } + }, "v1.vikunjaInfos": { "type": "object", "properties": { + "auth": { + "$ref": "#/definitions/v1.authInfo" + }, "available_migrators": { "type": "array", "items": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index a51e094646..d152ead809 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -15,6 +15,58 @@ }, "basePath": "/api/v1", "paths": { + "/auth/openid/{provider}/callback": { + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "After a redirect from the OpenID Connect provider to the frontend has been made with the authentication `code`, this endpoint can be used to obtain a jwt token for that user and thus log them in.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Authenticate a user with OpenID Connect", + "parameters": [ + { + "description": "The openid callback", + "name": "callback", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/openid.Callback" + } + }, + { + "type": "integer", + "description": "The OpenID Connect provider key as returned by the /info endpoint", + "name": "provider", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/auth.Token" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/backgrounds/unsplash/image/{image}": { "get": { "security": [ @@ -2409,7 +2461,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.Token" + "$ref": "#/definitions/auth.Token" } }, "400": { @@ -3654,7 +3706,7 @@ "200": { "description": "The valid jwt auth token.", "schema": { - "$ref": "#/definitions/v1.Token" + "$ref": "#/definitions/auth.Token" } }, "400": { @@ -6223,7 +6275,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.Token" + "$ref": "#/definitions/auth.Token" } }, "400": { @@ -6335,6 +6387,14 @@ } }, "definitions": { + "auth.Token": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, "background.Image": { "type": "object", "properties": { @@ -7413,6 +7473,34 @@ } } }, + "openid.Callback": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "openid.Provider": { + "type": "object", + "properties": { + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "todoist.Migration": { "type": "object", "properties": { @@ -7560,14 +7648,6 @@ } } }, - "v1.Token": { - "type": "object", - "properties": { - "token": { - "type": "string" - } - } - }, "v1.UserAvatarProvider": { "type": "object", "properties": { @@ -7587,6 +7667,17 @@ } } }, + "v1.authInfo": { + "type": "object", + "properties": { + "local": { + "$ref": "#/definitions/v1.localAuthInfo" + }, + "openid_connect": { + "$ref": "#/definitions/v1.openIDAuthInfo" + } + } + }, "v1.legalInfo": { "type": "object", "properties": { @@ -7598,9 +7689,37 @@ } } }, + "v1.localAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "v1.openIDAuthInfo": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/openid.Provider" + } + }, + "redirect_url": { + "type": "string" + } + } + }, "v1.vikunjaInfos": { "type": "object", "properties": { + "auth": { + "$ref": "#/definitions/v1.authInfo" + }, "available_migrators": { "type": "array", "items": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index a602d274e5..37f059dba7 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1,5 +1,10 @@ basePath: /api/v1 definitions: + auth.Token: + properties: + token: + type: string + type: object background.Image: properties: id: @@ -795,6 +800,24 @@ definitions: minLength: 1 type: string type: object + openid.Callback: + properties: + code: + type: string + scope: + type: string + type: object + openid.Provider: + properties: + auth_url: + type: string + client_id: + type: string + key: + type: string + name: + type: string + type: object todoist.Migration: properties: code: @@ -899,11 +922,6 @@ definitions: minLength: 1 type: string type: object - v1.Token: - properties: - token: - type: string - type: object v1.UserAvatarProvider: properties: avatar_provider: @@ -916,6 +934,13 @@ definitions: old_password: type: string type: object + v1.authInfo: + properties: + local: + $ref: '#/definitions/v1.localAuthInfo' + openid_connect: + $ref: '#/definitions/v1.openIDAuthInfo' + type: object v1.legalInfo: properties: imprint_url: @@ -923,8 +948,26 @@ definitions: privacy_policy_url: type: string type: object + v1.localAuthInfo: + properties: + enabled: + type: boolean + type: object + v1.openIDAuthInfo: + properties: + enabled: + type: boolean + providers: + items: + $ref: '#/definitions/openid.Provider' + type: array + redirect_url: + type: string + type: object v1.vikunjaInfos: properties: + auth: + $ref: '#/definitions/v1.authInfo' available_migrators: items: type: string @@ -1021,6 +1064,39 @@ paths: summary: User Avatar tags: - user + /auth/openid/{provider}/callback: + post: + consumes: + - application/json + description: After a redirect from the OpenID Connect provider to the frontend has been made with the authentication `code`, this endpoint can be used to obtain a jwt token for that user and thus log them in. + parameters: + - description: The openid callback + in: body + name: callback + required: true + schema: + $ref: '#/definitions/openid.Callback' + - description: The OpenID Connect provider key as returned by the /info endpoint + in: path + name: provider + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/auth.Token' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Authenticate a user with OpenID Connect + tags: + - auth /backgrounds/unsplash/image/{image}: get: description: Get an unsplash image. **Returns json on error.** @@ -2558,7 +2634,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.Token' + $ref: '#/definitions/auth.Token' "400": description: Invalid user password model. schema: @@ -3354,7 +3430,7 @@ paths: "200": description: The valid jwt auth token. schema: - $ref: '#/definitions/v1.Token' + $ref: '#/definitions/auth.Token' "400": description: Invalid link share object provided. schema: @@ -4997,7 +5073,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.Token' + $ref: '#/definitions/auth.Token' "400": description: Only user token are available for renew. schema: diff --git a/pkg/user/user.go b/pkg/user/user.go index ecc3f3b2e2..ef8e096370 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -23,10 +23,6 @@ import ( "reflect" "time" - "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/mail" - "code.vikunja.io/api/pkg/metrics" - "code.vikunja.io/api/pkg/utils" "code.vikunja.io/web" "github.com/dgrijalva/jwt-go" "github.com/labstack/echo/v4" @@ -49,7 +45,7 @@ type User struct { ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id"` // The username of the user. Is always unique. Username string `xorm:"varchar(250) not null unique" json:"username" valid:"length(1|250)" minLength:"1" maxLength:"250"` - Password string `xorm:"varchar(250) not null" json:"-"` + Password string `xorm:"varchar(250) null" json:"-"` // The user's email address. Email string `xorm:"varchar(250) null" json:"email,omitempty" valid:"email,length(0|250)" maxLength:"250"` IsActive bool `xorm:"null" json:"-"` @@ -60,6 +56,10 @@ type User struct { AvatarProvider string `xorm:"varchar(255) null" json:"-"` AvatarFileID int64 `xorn:"null" json:"-"` + // Issuer and Subject contain the issuer and subject from the source the user authenticated with. + Issuer string `xorm:"text null" json:"-"` + Subject string `xorm:"text null" json:"-"` + // A timestamp when this task was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this task was last updated. You cannot change this value. @@ -222,97 +222,6 @@ func GetUserFromClaims(claims jwt.MapClaims) (user *User, err error) { return } -// CreateUser creates a new user and inserts it into the database -func CreateUser(user *User) (newUser *User, err error) { - - newUser = user - - // Check if we have all needed informations - if newUser.Password == "" || newUser.Username == "" || newUser.Email == "" { - return &User{}, ErrNoUsernamePassword{} - } - - // Check if the user already existst with that username - exists := true - _, err = GetUserByUsername(newUser.Username) - if err != nil { - if IsErrUserDoesNotExist(err) { - exists = false - } else { - return &User{}, err - } - } - if exists { - return &User{}, ErrUsernameExists{newUser.ID, newUser.Username} - } - - // Check if the user already existst with that email - exists = true - _, err = GetUser(&User{Email: newUser.Email}) - if err != nil { - if IsErrUserDoesNotExist(err) { - exists = false - } else { - return &User{}, err - } - } - if exists { - return &User{}, ErrUserEmailExists{newUser.ID, newUser.Email} - } - - // Hash the password - newUser.Password, err = hashPassword(user.Password) - if err != nil { - return &User{}, err - } - - newUser.IsActive = true - if config.MailerEnabled.GetBool() { - // The new user should not be activated until it confirms his mail address - newUser.IsActive = false - // Generate a confirm token - newUser.EmailConfirmToken = utils.MakeRandomString(60) - } - - newUser.AvatarProvider = "initials" - - // Insert it - _, err = x.Insert(newUser) - if err != nil { - return &User{}, err - } - - // Update the metrics - metrics.UpdateCount(1, metrics.ActiveUsersKey) - - // Get the full new User - newUserOut, err := GetUser(newUser) - if err != nil { - return &User{}, err - } - - // Dont send a mail if we're testing - if !config.MailerEnabled.GetBool() { - return newUserOut, err - } - - // Send the user a mail with a link to confirm the mail - data := map[string]interface{}{ - "User": newUserOut, - "IsNew": true, - } - - mail.SendMailWithTemplate(user.Email, newUserOut.Username+" + Vikunja = <3", "confirm-email", data) - - return newUserOut, err -} - -// HashPassword hashes a password -func hashPassword(password string) (string, error) { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), 11) - return string(bytes), err -} - // UpdateUser updates a user func UpdateUser(user *User) (updatedUser *User, err error) { @@ -340,7 +249,11 @@ func UpdateUser(user *User) (updatedUser *User, err error) { if user.Email == "" { user.Email = theUser.Email } else { - uu, err := getUser(&User{Email: user.Email}, true) + uu, err := getUser(&User{ + Email: user.Email, + Issuer: user.Issuer, + Subject: user.Subject, + }, true) if err != nil && !IsErrUserDoesNotExist(err) { return nil, err } diff --git a/pkg/user/user_create.go b/pkg/user/user_create.go new file mode 100644 index 0000000000..74354c31d2 --- /dev/null +++ b/pkg/user/user_create.go @@ -0,0 +1,157 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package user + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/mail" + "code.vikunja.io/api/pkg/metrics" + "code.vikunja.io/api/pkg/utils" + "golang.org/x/crypto/bcrypt" +) + +const issuerLocal = `local` + +// CreateUser creates a new user and inserts it into the database +func CreateUser(user *User) (newUser *User, err error) { + + if user.Issuer == "" { + user.Issuer = issuerLocal + } + + // Check if we have all needed information + err = checkIfUserIsValid(user) + if err != nil { + return nil, err + } + + // Check if the user already exists with that username + err = checkIfUserExists(user) + if err != nil { + return nil, err + } + + if user.Issuer == issuerLocal { + // Hash the password + user.Password, err = hashPassword(user.Password) + if err != nil { + return nil, err + } + } + + user.IsActive = true + if config.MailerEnabled.GetBool() && user.Issuer == issuerLocal { + // The new user should not be activated until it confirms his mail address + user.IsActive = false + // Generate a confirm token + user.EmailConfirmToken = utils.MakeRandomString(60) + } + + user.AvatarProvider = "initials" + + // Insert it + _, err = x.Insert(user) + if err != nil { + return nil, err + } + + // Update the metrics + metrics.UpdateCount(1, metrics.ActiveUsersKey) + + // Get the full new User + newUserOut, err := GetUserByID(user.ID) + if err != nil { + return nil, err + } + + sendConfirmEmail(user) + + return newUserOut, err +} + +// HashPassword hashes a password +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 11) + return string(bytes), err +} + +func checkIfUserIsValid(user *User) error { + if user.Email == "" || + (user.Issuer != issuerLocal && user.Subject == "") || + (user.Issuer == issuerLocal && (user.Password == "" || + user.Username == "")) { + return ErrNoUsernamePassword{} + } + + return nil +} + +func checkIfUserExists(user *User) (err error) { + exists := true + _, err = GetUserByUsername(user.Username) + if err != nil { + if IsErrUserDoesNotExist(err) { + exists = false + } else { + return err + } + } + if exists { + return ErrUsernameExists{user.ID, user.Username} + } + + // Check if the user already existst with that email + exists = true + userToCheck := &User{ + Email: user.Email, + Issuer: user.Issuer, + Subject: user.Subject, + } + + if user.Issuer != issuerLocal { + userToCheck.Email = "" + } + + _, err = GetUser(userToCheck) + if err != nil { + if IsErrUserDoesNotExist(err) { + exists = false + } else { + return err + } + } + if exists && user.Issuer == issuerLocal { + return ErrUserEmailExists{user.ID, user.Email} + } + + return nil +} + +func sendConfirmEmail(user *User) { + // Dont send a mail if no mailer is configured + if !config.MailerEnabled.GetBool() { + return + } + + // Send the user a mail with a link to confirm the mail + data := map[string]interface{}{ + "User": user, + "IsNew": true, + } + + mail.SendMailWithTemplate(user.Email, user.Username+" + Vikunja = <3", "confirm-email", data) +} diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index d4bfce1523..0ba3843e17 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -88,6 +88,26 @@ func TestCreateUser(t *testing.T) { assert.Error(t, err) assert.True(t, IsErrNoUsernamePassword(err)) }) + t.Run("same email but different issuer", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + _, err := CreateUser(&User{ + Username: "somenewuser", + Email: "user1@example.com", + Issuer: "https://some.site", + Subject: "12345", + }) + assert.NoError(t, err) + }) + t.Run("same subject but different issuer", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + _, err := CreateUser(&User{ + Username: "somenewuser", + Email: "somenewuser@example.com", + Issuer: "https://some.site", + Subject: "12345", + }) + assert.NoError(t, err) + }) } func TestGetUser(t *testing.T) { @@ -256,7 +276,7 @@ func TestListUsers(t *testing.T) { db.LoadAndAssertFixtures(t) all, err := ListUsers("") assert.NoError(t, err) - assert.Len(t, all, 13) + assert.Len(t, all, 14) }) }