Authentication with OpenID Connect providers (#713)

Add config docs

Lint

Move provider-related stuff to separate file

Refactor getting auth providers

Fix tests

Fix user tests

Fix openid tests

Add swagger docs

Fix lint

Fix lint issues

Fix checking if the user already exists

Make sure to create a new namespace for new users

Docs

Add tests for openid

Remove unnessecary err check

Consistently return nil users if creating a new user failed

Move sending confirmation email to separate function

Better variable names

Move checks to separate functions

Refactor creating user into seperate file

Fix creating new local users

Test creating new users from different issuers

Generate a random username right away if no preferred username has been given

Add todo

Cache openid providers

Add getting int clientids

Fix migration

Move creating tokens to auth package

Add getting or creating a third party user

Add parsing claims

Add retreiving auth tokens

Add token callback from openid package

Add check for provider key

Add routes

Start adding openid auth handler

Add config for openid auth

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/713
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2020-11-21 16:38:58 +00:00
parent f67fe2ce25
commit 2b5c9ae7a8
37 changed files with 1265 additions and 178 deletions

View file

@ -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.<br/>
# The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
# **Note:** The frontend expects to be redirected after authentication by the third party
# to <frontend-url>/auth/openid/<auth key>. 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: <frontend url>
# 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:

View file

@ -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: `<empty>`
### openid
OpenID configuration will allow users to authenticate through a third-party OpenID Connect compatible provider.<br/>
The provider needs to support the `openid`, `profile` and `email` scopes.<br/>
**Note:** The frontend expects to be redirected after authentication by the third party
to <frontend-url>/auth/openid/<auth key>. 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: `<empty>`

5
go.mod
View file

@ -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

8
go.sum
View file

@ -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=

View file

@ -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")
},
}

View file

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

View file

@ -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

View file

@ -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) {

View file

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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,
},

View file

@ -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

View file

@ -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,
},

View file

@ -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,

View file

@ -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,
}

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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)

View file

@ -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 <https://www.gnu.org/licenses/>.
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())
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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)
})
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}

View file

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

View file

@ -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{}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": {

View file

@ -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": {

View file

@ -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:

View file

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

157
pkg/user/user_create.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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)
}

View file

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