2020-11-21 17:38:58 +01:00
// Vikunja is a to-do list application to facilitate your life.
2021-02-02 20:19:13 +01:00
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
2020-11-21 17:38:58 +01:00
//
// This program is free software: you can redistribute it and/or modify
2020-12-23 16:41:52 +01:00
// it under the terms of the GNU Affero General Public Licensee as published by
2020-11-21 17:38:58 +01:00
// 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
2020-12-23 16:41:52 +01:00
// GNU Affero General Public Licensee for more details.
2020-11-21 17:38:58 +01:00
//
2020-12-23 16:41:52 +01:00
// You should have received a copy of the GNU Affero General Public Licensee
2020-11-21 17:38:58 +01:00
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package openid
import (
"context"
"encoding/json"
"math/rand"
"net/http"
"time"
2021-05-16 13:23:10 +02:00
"code.vikunja.io/web/handler"
2020-12-23 16:32:28 +01:00
"code.vikunja.io/api/pkg/db"
"xorm.io/xorm"
2020-11-21 17:38:58 +01:00
"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 {
2021-05-28 10:52:32 +02:00
Name string ` json:"name" `
Key string ` json:"key" `
AuthURL string ` json:"auth_url" `
ClientID string ` json:"client_id" `
ClientSecret string ` json:"-" `
openIDProvider * oidc . Provider
2020-11-21 17:38:58 +01:00
Oauth2Config * oauth2 . Config ` json:"-" `
}
type claims struct {
Email string ` json:"email" `
Name string ` json:"name" `
PreferredUsername string ` json:"preferred_username" `
2021-05-19 14:45:24 +02:00
Nickname string ` json:"nickname" `
2020-11-21 17:38:58 +01:00
}
func init ( ) {
rand . Seed ( time . Now ( ) . UTC ( ) . UnixNano ( ) )
}
2021-05-28 10:52:32 +02:00
func ( p * Provider ) setOicdProvider ( ) ( err error ) {
p . openIDProvider , err = oidc . NewProvider ( context . Background ( ) , p . AuthURL )
return err
}
2020-11-21 17:38:58 +01:00
// 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 )
2021-05-16 13:23:10 +02:00
return handler . HandleHTTPError ( err , c )
2020-11-21 17:38:58 +01:00
}
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 {
2021-05-16 13:23:10 +02:00
log . Errorf ( "Error unmarshaling token for provider %s: %v" , provider . Name , err )
return handler . HandleHTTPError ( err , c )
2020-11-21 17:38:58 +01:00
}
return c . JSON ( http . StatusBadRequest , map [ string ] interface { } {
"message" : "Could not authenticate against third party." ,
"details" : details ,
} )
}
2021-05-16 13:23:10 +02:00
return handler . HandleHTTPError ( err , c )
2020-11-21 17:38:58 +01:00
}
// 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" } )
}
2021-05-28 10:52:32 +02:00
verifier := provider . openIDProvider . Verifier ( & oidc . Config { ClientID : provider . ClientID } )
2020-11-21 17:38:58 +01:00
// Parse and verify ID Token payload.
idToken , err := verifier . Verify ( context . Background ( ) , rawIDToken )
if err != nil {
2021-05-16 13:23:10 +02:00
log . Errorf ( "Error verifying token for provider %s: %v" , provider . Name , err )
return handler . HandleHTTPError ( err , c )
2020-11-21 17:38:58 +01:00
}
// Extract custom claims
cl := & claims { }
err = idToken . Claims ( cl )
if err != nil {
2021-05-16 13:23:10 +02:00
log . Errorf ( "Error getting token claims for provider %s: %v" , provider . Name , err )
return handler . HandleHTTPError ( err , c )
}
2021-05-19 14:45:24 +02:00
if cl . Email == "" || cl . Name == "" || cl . PreferredUsername == "" {
2021-05-28 10:52:32 +02:00
info , err := provider . openIDProvider . UserInfo ( context . Background ( ) , provider . Oauth2Config . TokenSource ( context . Background ( ) , oauth2Token ) )
2021-05-19 14:45:24 +02:00
if err != nil {
log . Errorf ( "Error getting userinfo for provider %s: %v" , provider . Name , err )
return handler . HandleHTTPError ( err , c )
}
cl2 := & claims { }
err = info . Claims ( cl2 )
if err != nil {
log . Errorf ( "Error parsing userinfo claims for provider %s: %v" , provider . Name , err )
return handler . HandleHTTPError ( err , c )
}
if cl . Email == "" {
cl . Email = cl2 . Email
}
if cl . Name == "" {
cl . Name = cl2 . Name
}
if cl . PreferredUsername == "" {
cl . PreferredUsername = cl2 . PreferredUsername
}
if cl . PreferredUsername == "" && cl2 . Nickname != "" {
cl . PreferredUsername = cl2 . Nickname
}
if cl . Email == "" {
log . Errorf ( "Claim does not contain an email address for provider %s" , provider . Name )
return handler . HandleHTTPError ( & user . ErrNoOpenIDEmailProvided { } , c )
}
2020-11-21 17:38:58 +01:00
}
2020-12-23 16:32:28 +01:00
s := db . NewSession ( )
defer s . Close ( )
2020-11-21 17:38:58 +01:00
// Check if we have seen this user before
2020-12-23 16:32:28 +01:00
u , err := getOrCreateUser ( s , cl , idToken . Issuer , idToken . Subject )
if err != nil {
_ = s . Rollback ( )
2021-05-16 13:23:10 +02:00
log . Errorf ( "Error creating new user for provider %s: %v" , provider . Name , err )
return handler . HandleHTTPError ( err , c )
2020-12-23 16:32:28 +01:00
}
err = s . Commit ( )
2020-11-21 17:38:58 +01:00
if err != nil {
2021-05-16 13:23:10 +02:00
return handler . HandleHTTPError ( err , c )
2020-11-21 17:38:58 +01:00
}
// Create token
return auth . NewUserAuthTokenResponse ( u , c )
}
2020-12-23 16:32:28 +01:00
func getOrCreateUser ( s * xorm . Session , cl * claims , issuer , subject string ) ( u * user . User , err error ) {
2020-11-21 17:38:58 +01:00
// Check if the user exists for that issuer and subject
2020-12-23 16:32:28 +01:00
u , err = user . GetUserWithEmail ( s , & user . User {
2020-11-21 17:38:58 +01:00
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 , "-" )
}
2020-12-23 16:32:28 +01:00
u , err = user . CreateUser ( s , uu )
2020-11-21 17:38:58 +01:00
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 , "-" )
2020-12-23 16:32:28 +01:00
u , err = user . CreateUser ( s , uu )
2020-11-21 17:38:58 +01:00
if err != nil {
return nil , err
}
}
// And create its namespace
2020-12-23 16:32:28 +01:00
err = models . CreateNewNamespaceForUser ( s , u )
2020-11-21 17:38:58 +01:00
if err != nil {
return nil , err
}
return
}
// If it exists, check if the email address changed and change it if not
2020-11-21 21:51:55 +01:00
if cl . Email != u . Email || cl . Name != u . Name {
if cl . Email != u . Email {
u . Email = cl . Email
}
if cl . Name != u . Name {
u . Name = cl . Name
}
2020-12-23 16:32:28 +01:00
u , err = user . UpdateUser ( s , & user . User {
2020-11-21 17:38:58 +01:00
ID : u . ID ,
2020-11-21 21:51:55 +01:00
Email : u . Email ,
Name : u . Name ,
2020-11-21 17:38:58 +01:00
Issuer : issuer ,
Subject : subject ,
} )
if err != nil {
return nil , err
}
}
return
}