2020-11-21 16:38:58 +00:00
// Vikunja is a to-do list application to facilitate your life.
2023-09-01 08:32:28 +02:00
// Copyright 2018-present Vikunja and contributors. All rights reserved.
2020-11-21 16:38:58 +00: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 16:38:58 +00: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 16:38:58 +00: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 16:38:58 +00:00
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package openid
import (
"context"
"encoding/json"
2022-03-27 16:55:37 +02:00
"errors"
2020-11-21 16:38:58 +00:00
"net/http"
2024-03-02 08:47:10 +00:00
"strconv"
2023-11-13 11:38:15 +01:00
"strings"
2020-11-21 16:38:58 +00:00
2021-05-16 13:23:10 +02:00
"code.vikunja.io/web/handler"
2020-12-23 15:32:28 +00:00
"code.vikunja.io/api/pkg/db"
2020-11-21 16:38:58 +00: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"
2024-03-02 08:47:10 +00:00
"code.vikunja.io/api/pkg/utils"
2021-06-14 20:56:29 +00:00
"github.com/coreos/go-oidc/v3/oidc"
2020-11-21 16:38:58 +00:00
petname "github.com/dustinkirkland/golang-petname"
"github.com/labstack/echo/v4"
"golang.org/x/oauth2"
2024-03-02 08:47:10 +00:00
"xorm.io/xorm"
2020-11-21 16:38:58 +00:00
)
// Callback contains the callback after an auth request was made and redirected
type Callback struct {
2024-01-28 12:41:35 +01:00
Code string ` query:"code" json:"code" `
Scope string ` query:"scop" json:"scope" `
2024-01-28 15:27:14 +01:00
RedirectURL string ` json:"redirect_url" `
2020-11-21 16:38:58 +00:00
}
// Provider is the structure of an OpenID Connect provider
type Provider struct {
2021-06-09 23:00:42 +02:00
Name string ` json:"name" `
Key string ` json:"key" `
OriginalAuthURL string ` json:"-" `
AuthURL string ` json:"auth_url" `
2022-12-18 18:26:28 +00:00
LogoutURL string ` json:"logout_url" `
2021-06-09 23:00:42 +02:00
ClientID string ` json:"client_id" `
2024-03-02 08:47:10 +00:00
Scope string ` json:"scope" `
2021-06-09 23:00:42 +02:00
ClientSecret string ` json:"-" `
openIDProvider * oidc . Provider
Oauth2Config * oauth2 . Config ` json:"-" `
2020-11-21 16:38:58 +00:00
}
type claims struct {
2024-03-02 08:47:10 +00:00
Email string ` json:"email" `
Name string ` json:"name" `
PreferredUsername string ` json:"preferred_username" `
Nickname string ` json:"nickname" `
VikunjaGroups [ ] map [ string ] interface { } ` json:"vikunja_groups" `
2020-11-21 16:38:58 +00:00
}
func init ( ) {
2023-03-05 22:24:25 +01:00
petname . NonDeterministicMode ( )
2020-11-21 16:38:58 +00:00
}
2021-05-28 10:52:32 +02:00
func ( p * Provider ) setOicdProvider ( ) ( err error ) {
2021-06-09 23:00:42 +02:00
p . openIDProvider , err = oidc . NewProvider ( context . Background ( ) , p . OriginalAuthURL )
2021-05-28 10:52:32 +02:00
return err
}
2020-11-21 16:38:58 +00: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.
2023-04-03 01:38:01 +02:00
// @ID get-token-openid
2020-11-21 16:38:58 +00:00
// @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 )
2024-03-02 08:47:10 +00:00
log . Debugf ( "Provider: %v" , provider )
2020-11-21 16:38:58 +00:00
if err != nil {
log . Error ( err )
2021-05-16 13:23:10 +02:00
return handler . HandleHTTPError ( err , c )
2020-11-21 16:38:58 +00:00
}
if provider == nil {
return c . JSON ( http . StatusBadRequest , models . Message { Message : "Provider does not exist" } )
}
2024-01-28 15:27:14 +01:00
provider . Oauth2Config . RedirectURL = cb . RedirectURL
2024-01-28 12:41:35 +01:00
2020-11-21 16:38:58 +00:00
// Parse the access & ID token
oauth2Token , err := provider . Oauth2Config . Exchange ( context . Background ( ) , cb . Code )
if err != nil {
2022-03-27 16:55:37 +02:00
var rerr * oauth2 . RetrieveError
if errors . As ( err , & rerr ) {
2020-11-21 16:38:58 +00:00
log . Error ( err )
details := make ( map [ string ] interface { } )
if err := json . Unmarshal ( rerr . Body , & details ) ; err != nil {
2022-03-27 16:55:37 +02:00
log . Errorf ( "Error unmarshalling token for provider %s: %v" , provider . Name , err )
2021-05-16 13:23:10 +02:00
return handler . HandleHTTPError ( err , c )
2020-11-21 16:38:58 +00: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 16:38:58 +00: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 16:38:58 +00: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 16:38:58 +00:00
}
// Extract custom claims
cl := & claims { }
2024-03-02 08:47:10 +00:00
2020-11-21 16:38:58 +00:00
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 16:38:58 +00:00
}
2020-12-23 15:32:28 +00:00
s := db . NewSession ( )
defer s . Close ( )
2020-11-21 16:38:58 +00:00
// Check if we have seen this user before
2020-12-23 15:32:28 +00: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 15:32:28 +00:00
}
2024-03-02 08:47:10 +00:00
// does the oidc token contain well formed "vikunja_groups" through vikunja_scope
log . Debugf ( "Checking for vikunja_groups in token %v" , cl . VikunjaGroups )
teamData , errs := getTeamDataFromToken ( cl . VikunjaGroups , provider )
if len ( teamData ) > 0 {
for _ , err := range errs {
log . Errorf ( "Error creating teams for user and vikunja groups %s: %v" , cl . VikunjaGroups , err )
}
2024-03-02 15:22:37 +01:00
// find old teams for user through oidc
2024-03-02 08:47:10 +00:00
oldOidcTeams , err := models . FindAllOidcTeamIDsForUser ( s , u . ID )
if err != nil {
log . Debugf ( "No oidc teams found for user %v" , err )
}
2024-03-04 20:26:45 +00:00
oidcTeams , err := AssignOrCreateUserToTeams ( s , u , teamData , idToken . Issuer )
2024-03-02 08:47:10 +00:00
if err != nil {
log . Errorf ( "Could not proceed with group routine %v" , err )
}
teamIDsToLeave := utils . NotIn ( oldOidcTeams , oidcTeams )
2024-03-10 13:47:19 +00:00
err = RemoveUserFromTeamsByIDs ( s , u , teamIDsToLeave )
2024-03-02 08:47:10 +00:00
if err != nil {
2024-03-11 17:20:05 +01:00
log . Errorf ( "Error while leaving teams %v" , err )
2024-03-02 08:47:10 +00:00
}
}
2020-12-23 15:32:28 +00:00
err = s . Commit ( )
2020-11-21 16:38:58 +00:00
if err != nil {
2024-03-02 08:47:10 +00:00
_ = s . Rollback ( )
log . Errorf ( "Error creating new team for provider %s: %v" , provider . Name , err )
2021-05-16 13:23:10 +02:00
return handler . HandleHTTPError ( err , c )
2020-11-21 16:38:58 +00:00
}
// Create token
2022-02-06 13:18:08 +00:00
return auth . NewUserAuthTokenResponse ( u , c , false )
2020-11-21 16:38:58 +00:00
}
2024-03-04 20:26:45 +00:00
func AssignOrCreateUserToTeams ( s * xorm . Session , u * user . User , teamData [ ] * models . OIDCTeam , issuer string ) ( oidcTeams [ ] int64 , err error ) {
2024-03-02 08:47:10 +00:00
if len ( teamData ) == 0 {
return
}
// check if we have seen these teams before.
// find or create Teams and assign user as teammember.
2024-03-04 20:26:45 +00:00
teams , err := GetOrCreateTeamsByOIDC ( s , teamData , u , issuer )
2024-03-02 08:47:10 +00:00
if err != nil {
log . Errorf ( "Error verifying team for %v, got %v. Error: %v" , u . Name , teams , err )
return nil , err
}
for _ , team := range teams {
tm := models . TeamMember { TeamID : team . ID , UserID : u . ID , Username : u . Username }
exists , _ := tm . MembershipExists ( s )
if ! exists {
err = tm . Create ( s , u )
if err != nil {
log . Errorf ( "Could not assign user %s to team %s: %v" , u . Username , team . Name , err )
}
}
oidcTeams = append ( oidcTeams , team . ID )
}
return oidcTeams , err
}
2024-03-10 13:47:19 +00:00
func RemoveUserFromTeamsByIDs ( s * xorm . Session , u * user . User , teamIDs [ ] int64 ) ( err error ) {
2024-03-02 08:47:10 +00:00
if len ( teamIDs ) < 1 {
return nil
}
log . Debugf ( "Removing team_member with user_id %v from team_ids %v" , u . ID , teamIDs )
_ , err = s . In ( "team_id" , teamIDs ) . And ( "user_id = ?" , u . ID ) . Delete ( & models . TeamMember { } )
return err
}
2024-03-02 15:22:37 +01:00
func getTeamDataFromToken ( groups [ ] map [ string ] interface { } , provider * Provider ) ( teamData [ ] * models . OIDCTeam , errs [ ] error ) {
teamData = [ ] * models . OIDCTeam { }
2024-03-02 08:47:10 +00:00
errs = [ ] error { }
for _ , team := range groups {
var name string
var description string
var oidcID string
2024-03-10 14:04:32 +00:00
var IsPublic bool
// Read name
2024-03-02 08:47:10 +00:00
_ , exists := team [ "name" ]
if exists {
name = team [ "name" ] . ( string )
}
2024-03-10 14:04:32 +00:00
// Read description
2024-03-02 08:47:10 +00:00
_ , exists = team [ "description" ]
if exists {
description = team [ "description" ] . ( string )
}
2024-03-10 14:04:32 +00:00
// Read isPublic flag
_ , exists = team [ "isPublic" ]
if exists {
IsPublic = team [ "isPublic" ] . ( bool )
}
// Read oidcID
2024-03-02 08:47:10 +00:00
_ , exists = team [ "oidcID" ]
if exists {
switch t := team [ "oidcID" ] . ( type ) {
2024-03-02 15:22:37 +01:00
case string :
oidcID = team [ "oidcID" ] . ( string )
2024-03-02 08:47:10 +00:00
case int64 :
oidcID = strconv . FormatInt ( team [ "oidcID" ] . ( int64 ) , 10 )
case float64 :
oidcID = strconv . FormatFloat ( team [ "oidcID" ] . ( float64 ) , 'f' , - 1 , 64 )
default :
log . Errorf ( "No oidcID assigned for %v or type %v not supported" , team , t )
}
}
if name == "" || oidcID == "" {
log . Errorf ( "Claim of your custom scope does not hold name or oidcID for automatic group assignment through oidc provider. Please check %s" , provider . Name )
errs = append ( errs , & user . ErrOpenIDCustomScopeMalformed { } )
continue
}
2024-03-10 14:04:32 +00:00
teamData = append ( teamData , & models . OIDCTeam { Name : name , OidcID : oidcID , Description : description , IsPublic : IsPublic } )
2024-03-02 08:47:10 +00:00
}
return teamData , errs
}
2024-03-02 15:27:15 +01:00
func getOIDCTeamName ( name string ) string {
return name + " (OIDC)"
}
2024-03-04 20:26:45 +00:00
func CreateOIDCTeam ( s * xorm . Session , teamData * models . OIDCTeam , u * user . User , issuer string ) ( team * models . Team , err error ) {
2024-03-02 08:47:10 +00:00
team = & models . Team {
2024-03-02 15:27:15 +01:00
Name : getOIDCTeamName ( teamData . Name ) ,
2024-03-02 08:47:10 +00:00
Description : teamData . Description ,
OidcID : teamData . OidcID ,
2024-03-04 20:26:45 +00:00
Issuer : issuer ,
2024-03-10 14:04:32 +00:00
IsPublic : teamData . IsPublic ,
2024-03-02 08:47:10 +00:00
}
2024-03-05 22:08:39 +00:00
err = team . CreateNewTeam ( s , u , false )
2024-03-02 08:47:10 +00:00
return team , err
}
2024-03-04 20:26:45 +00:00
// GetOrCreateTeamsByOIDC returns a slice of teams which were generated from the oidc data. If a team did not exist previously it is automatically created.
func GetOrCreateTeamsByOIDC ( s * xorm . Session , teamData [ ] * models . OIDCTeam , u * user . User , issuer string ) ( te [ ] * models . Team , err error ) {
2024-03-02 08:47:10 +00:00
te = [ ] * models . Team { }
// Procedure can only be successful if oidcID is set
for _ , oidcTeam := range teamData {
2024-03-04 20:26:45 +00:00
team , err := models . GetTeamByOidcIDAndIssuer ( s , oidcTeam . OidcID , issuer )
2024-03-02 15:22:37 +01:00
if err != nil && ! models . IsErrOIDCTeamDoesNotExist ( err ) {
return nil , err
}
if err != nil && models . IsErrOIDCTeamDoesNotExist ( err ) {
log . Debugf ( "Team with oidc_id %v and name %v does not exist. Creating team… " , oidcTeam . OidcID , oidcTeam . Name )
2024-03-04 20:26:45 +00:00
newTeam , err := CreateOIDCTeam ( s , oidcTeam , u , issuer )
2024-03-02 08:47:10 +00:00
if err != nil {
return te , err
}
te = append ( te , newTeam )
2024-03-02 15:22:37 +01:00
continue
2024-03-02 08:47:10 +00:00
}
2024-03-02 15:22:37 +01:00
2024-03-10 14:04:32 +00:00
// Compare the name and update if it changed
2024-03-02 15:27:15 +01:00
if team . Name != getOIDCTeamName ( oidcTeam . Name ) {
team . Name = getOIDCTeamName ( oidcTeam . Name )
2024-03-10 14:04:32 +00:00
}
// Compare the description and update if it changed
if team . Description != oidcTeam . Description {
team . Description = oidcTeam . Description
}
// Compare the isPublic flag and update if it changed
if team . IsPublic != oidcTeam . IsPublic {
team . IsPublic = oidcTeam . IsPublic
}
err = team . Update ( s , u )
if err != nil {
return nil , err
2024-03-02 15:27:15 +01:00
}
2024-03-02 15:22:37 +01:00
log . Debugf ( "Team with oidc_id %v and name %v already exists." , team . OidcID , team . Name )
te = append ( te , team )
2024-03-02 08:47:10 +00:00
}
return te , err
}
2020-12-23 15:32:28 +00:00
func getOrCreateUser ( s * xorm . Session , cl * claims , issuer , subject string ) ( u * user . User , err error ) {
2024-03-02 08:47:10 +00:00
2020-11-21 16:38:58 +00:00
// Check if the user exists for that issuer and subject
2020-12-23 15:32:28 +00:00
u , err = user . GetUserWithEmail ( s , & user . User {
2020-11-21 16:38:58 +00: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 {
2023-11-13 11:38:15 +01:00
Username : strings . ReplaceAll ( cl . PreferredUsername , " " , "-" ) ,
2020-11-21 16:38:58 +00:00
Email : cl . Email ,
2021-12-12 12:35:13 +01:00
Name : cl . Name ,
2021-07-13 20:56:02 +00:00
Status : user . StatusActive ,
2020-11-21 16:38:58 +00:00
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 15:32:28 +00:00
u , err = user . CreateUser ( s , uu )
2020-11-21 16:38:58 +00:00
if err != nil && ! user . IsErrUsernameExists ( err ) {
return nil , err
}
2023-11-13 11:38:15 +01:00
// If their preferred username is already taken, generate a random one
2020-11-21 16:38:58 +00:00
if user . IsErrUsernameExists ( err ) {
uu . Username = petname . Generate ( 3 , "-" )
2020-12-23 15:32:28 +00:00
u , err = user . CreateUser ( s , uu )
2020-11-21 16:38:58 +00:00
if err != nil {
return nil , err
}
}
2022-12-29 17:51:55 +01:00
// And create their project
2022-12-29 16:40:06 +01:00
err = models . CreateNewProjectForUser ( s , u )
2020-11-21 16:38:58 +00: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
}
2023-12-25 17:09:19 +01:00
u , err = user . UpdateUser ( s , u , false )
2020-11-21 16:38:58 +00:00
if err != nil {
return nil , err
}
}
return
}