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