diff --git a/config.yml.sample b/config.yml.sample
index 6f69f81b..e13576f2 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 619867b5..6ee7779d 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 442cde9a..fc52a5b1 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 42c2d032..950727c5 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 0c2c13a4..b1f37bd3 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 f17f7b26..6dfd0044 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 268a3fa1..9c152be6 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 a625c6f9..1928a007 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 00000000..73d5c62e
--- /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 3bcdf50e..a7f26558 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 dc96b65e..add57c28 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 a9456911..2d29efae 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 e3b77b8e..c6acd657 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 69dc8091..554bcae2 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 af999503..a333dfdc 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 955a7261..264009ec 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 2367488d..4729e0e3 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 00000000..17f1f402
--- /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 00000000..8a9b77d9
--- /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 00000000..4e29727b
--- /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 00000000..7d4133ba
--- /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 2fecef56..2c1921c4 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 646d43f5..faa06db5 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 ba62c855..0040cfec 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 e9733724..79ab6ae7 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 5a995d13..1d3cc61f 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 2b5053fe..ca925516 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 3130d731..4c19b205 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 61722f06..e984feb2 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 a038081e..e6d8ff60 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 dfdb4844..87e00b8d 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 be130778..516fd0ec 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 a51e0946..d152ead8 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 a602d274..37f059db 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 ecc3f3b2..ef8e0963 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 00000000..74354c31
--- /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 d4bfce15..0ba3843e 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)
})
}