Add support to login using identity from an identity-aware proxy #715
|
@ -285,6 +285,17 @@ auth:
|
|||
# The client secret used to authenticate Vikunja at the OpenID Connect provider.
|
||||
clientsecret:
|
||||
|
||||
# Identity-Aware Proxy configuration will allow users to authenticate through an identity-aware proxy in front of Vikunja.<br/>
|
||||
# The proxy needs to supply a signed JWT over HTTP headers with claims for `sub`, `iss`, and `email`.
|
||||
# It must also serve the public keys for that signed JWT in the JWKS format.
|
||||
identityawareproxy:
|
||||
# Enable or disable Identity-Aware Proxy authentication
|
||||
enabled: false
|
||||
# The URL of the proxy's JWKS endpoint
|
||||
jwksuri: <proxy's JWKS url>
|
||||
# The HTTP header containing the signed JWT from the proxy
|
||||
jwtheader:
|
||||
|
||||
# Prometheus metrics endpoint
|
||||
metrics:
|
||||
# If set to true, enables a /metrics endpoint for prometheus to collect metrics about Vikunja.
|
||||
|
@ -292,4 +303,4 @@ metrics:
|
|||
# If set to a non-empty value the /metrics endpoint will require this as a username via basic auth in combination with the password below.
|
||||
username:
|
||||
# If set to a non-empty value the /metrics endpoint will require this as a password via basic auth in combination with the username below.
|
||||
password:
|
||||
password:
|
|
@ -650,3 +650,12 @@ If set to a non-empty value the /metrics endpoint will require this as a passwor
|
|||
|
||||
Default: `<empty>`
|
||||
|
||||
### identityawareproxy
|
||||
|
||||
Identity-Aware Proxy configuration will allow users to authenticate through an identity-aware proxy in front of Vikunja.<br/>
|
||||
The proxy needs to supply a signed JWT over HTTP headers with claims for `sub`, `iss`, and `email`.
|
||||
This JWT must be signed using ECDSA or RSA.
|
||||
It must also serve the public keys for that signed JWT in the JWKS format.
|
||||
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 this authentication.
|
||||
|
||||
Default: `<empty>`
|
1
go.mod
|
@ -45,6 +45,7 @@ require (
|
|||
github.com/labstack/echo/v4 v4.3.0
|
||||
github.com/labstack/gommon v0.3.0
|
||||
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef
|
||||
github.com/lestrrat-go/jwx v1.0.6
|
||||
github.com/lib/pq v1.10.2
|
||||
github.com/magefile/mage v1.11.0
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
|
|
8
go.sum
|
@ -466,6 +466,13 @@ github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL
|
|||
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef h1:RZnRnSID1skF35j/15KJ6hKZkdIC/teQClJK5wP5LU4=
|
||||
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef/go.mod h1:4LATl0uhhtytR6p9n1AlktDyIz4u2iUnWEdI3L/hXiw=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911 h1:FvnrqecqX4zT0wOIbYK1gNgTm0677INEWiFY8UEYggY=
|
||||
github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
|
||||
github.com/lestrrat-go/jwx v1.0.6 h1:0absmJ/XlsxNkXr9syeIHjCJnu3rZa+DKzdCI6QfYgU=
|
||||
github.com/lestrrat-go/jwx v1.0.6/go.mod h1:NNxs6i86gQDGEqgIszN/pkJihMqzYrXMIJt2Yhxhkvs=
|
||||
github.com/lestrrat-go/pdebug v0.0.0-20200204225717-4d6bd78da58d h1:aEZT3f1GGg5RIlHMAy4/4fe4ciOi3SCwYoaURphcB4k=
|
||||
github.com/lestrrat-go/pdebug v0.0.0-20200204225717-4d6bd78da58d/go.mod h1:B06CSso/AWxiPejj+fheUINGeBKeeEZNt8w+EoU7+L8=
|
||||
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
|
@ -1038,6 +1045,7 @@ golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapK
|
|||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200417140056-c07e33ef3290/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
|
|
@ -55,10 +55,13 @@ const (
|
|||
ServiceTestingtoken Key = `service.testingtoken`
|
||||
ServiceEnableEmailReminders Key = `service.enableemailreminders`
|
||||
|
||||
AuthLocalEnabled Key = `auth.local.enabled`
|
||||
AuthOpenIDEnabled Key = `auth.openid.enabled`
|
||||
AuthOpenIDRedirectURL Key = `auth.openid.redirecturl`
|
||||
AuthOpenIDProviders Key = `auth.openid.providers`
|
||||
AuthLocalEnabled Key = `auth.local.enabled`
|
||||
AuthOpenIDEnabled Key = `auth.openid.enabled`
|
||||
AuthOpenIDRedirectURL Key = `auth.openid.redirecturl`
|
||||
AuthOpenIDProviders Key = `auth.openid.providers`
|
||||
AuthIdentityAwareProxyEnabled Key = `auth.identityawareproxy.enabled`
|
||||
AuthIdentityAwareProxyJwksUri Key = `auth.identityawareproxy.jwksuri`
|
||||
AuthIdentityAwareProxyJwtHeader Key = `auth.identityawareproxy.jwtheader`
|
||||
|
||||
LegalImprintURL Key = `legal.imprinturl`
|
||||
LegalPrivacyURL Key = `legal.privacyurl`
|
||||
|
@ -247,6 +250,7 @@ func InitDefaultConfig() {
|
|||
// Auth
|
||||
AuthLocalEnabled.setDefault(true)
|
||||
AuthOpenIDEnabled.setDefault(false)
|
||||
AuthIdentityAwareProxyEnabled.setDefault(false)
|
||||
|
||||
// Database
|
||||
DatabaseType.setDefault("sqlite")
|
||||
|
|
|
@ -121,27 +121,31 @@ func newTestRequest(t *testing.T, method string, handler func(ctx echo.Context)
|
|||
}
|
||||
|
||||
func addUserTokenToContext(t *testing.T, user *user.User, c echo.Context) {
|
||||
jwtConfig := auth.GetJWTConfig()
|
||||
|
||||
// Get the token as a string
|
||||
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) {
|
||||
tken, err := jwt.ParseWithClaims(token, jwtConfig.Claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.ServiceJWTSecret.GetString()), nil
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
c.Set("user", tken)
|
||||
c.Set(jwtConfig.ContextKey, tken)
|
||||
}
|
||||
|
||||
func addLinkShareTokenToContext(t *testing.T, share *models.LinkSharing, c echo.Context) {
|
||||
jwtConfig := auth.GetJWTConfig()
|
||||
|
||||
// Get the token as a string
|
||||
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) {
|
||||
tken, err := jwt.ParseWithClaims(token, jwtConfig.Claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.ServiceJWTSecret.GetString()), nil
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
c.Set("user", tken)
|
||||
c.Set(jwtConfig.ContextKey, tken)
|
||||
}
|
||||
|
||||
func testRequestSetup(t *testing.T, method string, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, c echo.Context) {
|
||||
|
|
|
@ -25,7 +25,6 @@ import (
|
|||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
"code.vikunja.io/web"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
|
@ -81,17 +80,6 @@ func (share *LinkSharing) GetID() int64 {
|
|||
return share.ID
|
||||
}
|
||||
|
||||
// GetLinkShareFromClaims builds a link sharing object from jwt claims
|
||||
func GetLinkShareFromClaims(claims jwt.MapClaims) (share *LinkSharing, err error) {
|
||||
share = &LinkSharing{}
|
||||
share.ID = int64(claims["id"].(float64))
|
||||
share.Hash = claims["hash"].(string)
|
||||
share.ListID = int64(claims["list_id"].(float64))
|
||||
share.Right = Right(claims["right"].(float64))
|
||||
share.SharedByID = int64(claims["sharedByID"].(float64))
|
||||
return
|
||||
}
|
||||
|
||||
func (share *LinkSharing) getUserID() int64 {
|
||||
return share.ID * -1
|
||||
}
|
||||
|
|
|
@ -20,26 +20,78 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"xorm.io/xorm"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
petname "github.com/dustinkirkland/golang-petname"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// These are all valid auth types
|
||||
type AuthType int
|
||||
|
||||
const (
|
||||
AuthTypeUnknown int = iota
|
||||
AuthTypeUnknown AuthType = iota
|
||||
AuthTypeUser
|
||||
AuthTypeLinkShare
|
||||
AuthTypeIAPUser
|
||||
)
|
||||
|
||||
// Token represents an authentification token
|
||||
const authTokenContextKey string = "authToken"
|
||||
|
||||
// Token represents an authentification token in signed string form
|
||||
type Token struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Claims made in the JWT token for various auth types
|
||||
// Only the auth module should introspect and handle claims
|
||||
type AuthClaims struct {
|
||||
// Common to all claims
|
||||
Type AuthType `json:"type"`
|
||||
|
||||
// AuthTypeUser and AuthTypeIAPUser claims
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
UserUsername string `json:"user_username,omitempty"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
|
||||
UserEmailRemindersEnabled bool `json:"user_email_reminders_enabled"`
|
||||
|
||||
// AuthTypeLinkShare claims
|
||||
ShareID int64 `json:"share_id,omitempty"`
|
||||
ShareHash string `json:"share_hash,omitempty"`
|
||||
ShareListID int64 `json:"share_list_id,omitempty"`
|
||||
ShareRight models.Right `json:"share_right,omitempty"`
|
||||
ShareSharedByID int64 `json:"share_shared_by_id,omitempty"`
|
||||
|
||||
// Common claims
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
// An AuthProvider provides alternative methods of authentication
|
||||
// In these cases, AuthClaims may contain hints to the user identity,
|
||||
// but an outside source is the final source-of-truth for auth (e.g. Identity-Aware Proxy auth)
|
||||
type AuthProvider interface {
|
||||
GetWebAuth(echo.Context, *AuthClaims) (web.Auth, error)
|
||||
|
||||
}
|
||||
|
||||
var authProviders = map[AuthType]AuthProvider{}
|
||||
|
||||
func RegisterAuthProvider(t AuthType, provider AuthProvider) {
|
||||
authProviders[t] = provider
|
||||
}
|
||||
|
||||
// NewTokenResponse creates a new token response from a token
|
||||
func NewTokenResponse(t string, c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, Token{Token: t})
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
@ -47,22 +99,25 @@ func NewUserAuthTokenResponse(u *user.User, c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, Token{Token: t})
|
||||
return NewTokenResponse(t, c)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
func NewUserJWTAuthtoken(u *user.User) (token string, err error) {
|
||||
// Set claims
|
||||
claims := t.Claims.(jwt.MapClaims)
|
||||
claims["type"] = AuthTypeUser
|
||||
claims["id"] = user.ID
|
||||
claims["username"] = user.Username
|
||||
claims["email"] = user.Email
|
||||
claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
|
||||
claims["name"] = user.Name
|
||||
claims["emailRemindersEnabled"] = user.EmailRemindersEnabled
|
||||
claims := &AuthClaims{
|
||||
Type: AuthTypeUser,
|
||||
UserID: u.ID,
|
||||
UserUsername: u.Username,
|
||||
UserEmail: u.Email,
|
||||
UserName: u.Name,
|
||||
UserEmailRemindersEnabled: u.EmailRemindersEnabled,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: time.Now().Add(time.Hour * 72).Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Generate encoded token and send it as response.
|
||||
return t.SignedString([]byte(config.ServiceJWTSecret.GetString()))
|
||||
|
@ -70,32 +125,199 @@ func NewUserJWTAuthtoken(user *user.User) (token string, err error) {
|
|||
|
||||
// NewLinkShareJWTAuthtoken creates a new jwt token from a link share
|
||||
func NewLinkShareJWTAuthtoken(share *models.LinkSharing) (token string, err error) {
|
||||
t := jwt.New(jwt.SigningMethodHS256)
|
||||
|
||||
// Set claims
|
||||
claims := t.Claims.(jwt.MapClaims)
|
||||
claims["type"] = AuthTypeLinkShare
|
||||
claims["id"] = share.ID
|
||||
claims["hash"] = share.Hash
|
||||
claims["list_id"] = share.ListID
|
||||
claims["right"] = share.Right
|
||||
claims["sharedByID"] = share.SharedByID
|
||||
claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
|
||||
claims := &AuthClaims{
|
||||
Type: AuthTypeLinkShare,
|
||||
ShareID: share.ID,
|
||||
ShareHash: share.Hash,
|
||||
ShareListID: share.ListID,
|
||||
ShareRight: share.Right,
|
||||
ShareSharedByID: share.SharedByID,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: time.Now().Add(time.Hour * 72).Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Generate encoded token and send it as response.
|
||||
return t.SignedString([]byte(config.ServiceJWTSecret.GetString()))
|
||||
}
|
||||
|
||||
// GetAuthFromClaims returns a web.Auth object from jwt claims
|
||||
// GetAuthFromClaims returns a web.Auth object from jwt claims or from an
|
||||
// alternative authProvider
|
||||
func GetAuthFromClaims(c echo.Context) (a web.Auth, err error) {
|
||||
jwtinf := c.Get("user").(*jwt.Token)
|
||||
claims := jwtinf.Claims.(jwt.MapClaims)
|
||||
typ := int(claims["type"].(float64))
|
||||
if typ == AuthTypeLinkShare && config.ServiceEnableLinkSharing.GetBool() {
|
||||
return models.GetLinkShareFromClaims(claims)
|
||||
jwtinf := c.Get(authTokenContextKey).(*jwt.Token)
|
||||
claims := jwtinf.Claims.(*AuthClaims)
|
||||
if claims.Type == AuthTypeLinkShare && config.ServiceEnableLinkSharing.GetBool() {
|
||||
return getLinkShareFromClaims(claims)
|
||||
}
|
||||
if typ == AuthTypeUser {
|
||||
return user.GetUserFromClaims(claims)
|
||||
if claims.Type == AuthTypeUser {
|
||||
return getUserFromClaims(claims)
|
||||
}
|
||||
if authProvider, ok := authProviders[claims.Type]; ok {
|
||||
return authProvider.GetWebAuth(c, claims)
|
||||
}
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, models.Message{Message: "Invalid JWT token."})
|
||||
}
|
||||
|
||||
// getLinkShareFromClaims builds a link sharing object from jwt claims
|
||||
func getLinkShareFromClaims(claims *AuthClaims) (share *models.LinkSharing, err error) {
|
||||
share = &models.LinkSharing{}
|
||||
share.ID = claims.ShareID
|
||||
share.Hash = claims.ShareHash
|
||||
share.ListID = claims.ShareListID
|
||||
share.Right = claims.ShareRight
|
||||
share.SharedByID = claims.ShareSharedByID
|
||||
|
||||
if share.Hash == "" {
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, models.Message{Message: "Invalid JWT token."})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// getUserFromClaims Returns a new user from jwt claims
|
||||
func getUserFromClaims(claims *AuthClaims) (u *user.User, err error) {
|
||||
u = &user.User{
|
||||
ID: claims.UserID,
|
||||
Email: claims.UserEmail,
|
||||
Username: claims.UserUsername,
|
||||
Name: claims.UserName,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the current user based on its jwt token
|
||||
func GetCurrentUser(c echo.Context) (u *user.User, err error) {
|
||||
auth, err := GetAuthFromClaims(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u, ok := auth.(*user.User)
|
||||
if !ok {
|
||||
return nil, user.ErrCouldNotGetUserID{}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Generates a new jwt token for the types AuthTypeLinkShare and AuthTypeUser
|
||||
func RenewToken(s *xorm.Session, c echo.Context) (token string, err error) {
|
||||
jwtinf := c.Get(authTokenContextKey).(*jwt.Token)
|
||||
claims := jwtinf.Claims.(*AuthClaims)
|
||||
|
||||
if claims.Type == AuthTypeLinkShare {
|
||||
oldShare, err := getLinkShareFromClaims(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
share := &models.LinkSharing{}
|
||||
share.ID = oldShare.ID
|
||||
err = share.ReadOne(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return NewLinkShareJWTAuthtoken(share)
|
||||
}
|
||||
if claims.Type == AuthTypeUser {
|
||||
oldUser, err := getUserFromClaims(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u, err := user.GetUserWithEmail(s, &user.User{ID: oldUser.ID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return NewUserJWTAuthtoken(u)
|
||||
}
|
||||
return "", echo.NewHTTPError(http.StatusBadRequest, models.Message{Message: "Invalid JWT token."})
|
||||
}
|
||||
|
||||
// GetJWTConfig returns the config for the default JWT middleware
|
||||
func GetJWTConfig() middleware.JWTConfig {
|
||||
return middleware.JWTConfig{
|
||||
SigningKey: []byte(config.ServiceJWTSecret.GetString()),
|
||||
SigningMethod: middleware.AlgorithmHS256,
|
||||
ContextKey: authTokenContextKey,
|
||||
Claims: &AuthClaims{},
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCreateUserFromExternalAuth returns a user after finding or creating a matching user for the provided details
|
||||
func GetOrCreateUserFromExternalAuth(s *xorm.Session, issuer, subject, email, name, preferredUsername string) (u *user.User, err error) {
|
||||
if issuer == "" || subject == "" || email == "" {
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, models.Message{Message: "Missing required data."})
|
||||
}
|
||||
|
||||
// Check if the user exists for that issuer and subject
|
||||
u, err = user.GetUserWithEmail(s, &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: preferredUsername,
|
||||
Email: 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(s, 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(s, uu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// And create its namespace
|
||||
err = models.CreateNewNamespaceForUser(s, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If it exists, check if the email address changed and change it if not
|
||||
if email != u.Email || (name != "" && name != u.Name) {
|
||||
if email != u.Email {
|
||||
u.Email = email
|
||||
}
|
||||
if name != "" && name != u.Name {
|
||||
u.Name = name
|
||||
}
|
||||
u, err = user.UpdateUser(s, &user.User{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Name: u.Name,
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package openid
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
@ -29,18 +29,13 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
cl := &claims{
|
||||
Email: "test@example.com",
|
||||
PreferredUsername: "someUserWhoDoesNotExistYet",
|
||||
}
|
||||
u, err := getOrCreateUser(s, cl, "https://some.issuer", "12345")
|
||||
u, err := GetOrCreateUserFromExternalAuth(s, "https://some.issuer", "12345", "test@example.com", "", "someUserWhoDoesNotExistYet")
|
||||
assert.NoError(t, err)
|
||||
err = s.Commit()
|
||||
assert.NoError(t, err)
|
||||
|
||||
db.AssertExists(t, "users", map[string]interface{}{
|
||||
"id": u.ID,
|
||||
"email": cl.Email,
|
||||
"email": "test@example.com",
|
||||
"username": "someUserWhoDoesNotExistYet",
|
||||
}, false)
|
||||
})
|
||||
|
@ -49,11 +44,7 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
cl := &claims{
|
||||
Email: "test@example.com",
|
||||
PreferredUsername: "",
|
||||
}
|
||||
u, err := getOrCreateUser(s, cl, "https://some.issuer", "12345")
|
||||
u, err := GetOrCreateUserFromExternalAuth(s, "https://some.issuer", "12345", "test@example.com", "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, u.Username)
|
||||
err = s.Commit()
|
||||
|
@ -61,7 +52,7 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
|
||||
db.AssertExists(t, "users", map[string]interface{}{
|
||||
"id": u.ID,
|
||||
"email": cl.Email,
|
||||
"email": "test@example.com",
|
||||
}, false)
|
||||
})
|
||||
t.Run("new user, no email address", func(t *testing.T) {
|
||||
|
@ -69,10 +60,7 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
cl := &claims{
|
||||
Email: "",
|
||||
}
|
||||
_, err := getOrCreateUser(s, cl, "https://some.issuer", "12345")
|
||||
_, err := GetOrCreateUserFromExternalAuth(s, "https://some.issuer", "12345", "", "", "")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
t.Run("existing user, different email address", func(t *testing.T) {
|
||||
|
@ -80,17 +68,14 @@ func TestGetOrCreateUser(t *testing.T) {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
cl := &claims{
|
||||
Email: "other-email-address@some.service.com",
|
||||
}
|
||||
u, err := getOrCreateUser(s, cl, "https://some.service.com", "12345")
|
||||
u, err := GetOrCreateUserFromExternalAuth(s, "https://some.service.com", "12345", "other-email-address@some.service.com", "", "")
|
||||
assert.NoError(t, err)
|
||||
err = s.Commit()
|
||||
assert.NoError(t, err)
|
||||
|
||||
db.AssertExists(t, "users", map[string]interface{}{
|
||||
"id": u.ID,
|
||||
"email": cl.Email,
|
||||
"email": "other-email-address@some.service.com",
|
||||
}, false)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
// 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 identityawareproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/web"
|
||||
)
|
||||
|
||||
// ErrIAPTokenMissing represents a "IAPTokenMissing" kind of error.
|
||||
type ErrIAPTokenMissing struct {
|
||||
Header string
|
||||
}
|
||||
|
||||
// IsErrIAPTokenMissing checks if an error is a ErrIAPTokenMissing.
|
||||
func IsErrIAPTokenMissing(err error) bool {
|
||||
_, ok := err.(ErrIAPTokenMissing)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIAPTokenMissing) Error() string {
|
||||
return fmt.Sprintf("No JWT provided by the identity-aware proxy at the header %v", err.Header)
|
||||
}
|
||||
|
||||
// ErrorCodeIAPTokenMissing holds the unique world-error code of this error
|
||||
const ErrorCodeIAPTokenMissing = 12001
|
||||
konrad
commented
This should be documented in This should be documented in `docs/content/doc/usage/errors.md`
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrIAPTokenMissing) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusServiceUnavailable, Code: ErrorCodeIAPTokenMissing, Message: "No authentication provided by the identity-aware proxy."}
|
||||
}
|
||||
|
||||
// ErrIAPPublicKeysetMissing represents a "IAPPublicKeysetMissing" kind of error.
|
||||
type ErrIAPPublicKeysetMissing struct {
|
||||
Url string
|
||||
}
|
||||
|
||||
// IsErrIAPPublicKeysetMissing checks if an error is a ErrIAPPublicKeysetMissing.
|
||||
func IsErrIAPPublicKeysetMissing(err error) bool {
|
||||
_, ok := err.(ErrIAPPublicKeysetMissing)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIAPPublicKeysetMissing) Error() string {
|
||||
return fmt.Sprintf("Failed to retrive the identity-aware proxy's signing public key at URL: %s", err.Url)
|
||||
}
|
||||
|
||||
// ErrorCodeIAPPublicKeysetMissing holds the unique world-error code of this error
|
||||
const ErrorCodeIAPPublicKeysetMissing = 12002
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrIAPPublicKeysetMissing) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusServiceUnavailable, Code: ErrorCodeIAPPublicKeysetMissing, Message: "Failed to retrive the identity-aware proxy's signing public keys."}
|
||||
}
|
||||
|
||||
// ErrIAPUserFrontendMismatch represents a "IAPUserDoesNotMatchFrontendUser" kind of error.
|
||||
type ErrIAPUserFrontendMismatch struct {}
|
||||
|
||||
// IsErrIAPPublicKeysetMissing checks if an error is a ErrIAPUserFrontendMismatch.
|
||||
func IsErrIAPUserFrontendMismatch(err error) bool {
|
||||
_, ok := err.(ErrIAPUserFrontendMismatch)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIAPUserFrontendMismatch) Error() string {
|
||||
return "Frontend provided user does not match IAP provided user"
|
||||
}
|
||||
|
||||
// ErrorCodeIAPPublicKeysetMissing holds the unique world-error code of this error
|
||||
const ErrorCodeIAPUserFrontendMismatch = 12003
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrIAPUserFrontendMismatch) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrorCodeIAPUserFrontendMismatch, Message: "Invalid provided jwt."}
|
||||
}
|
||||
|
||||
// ErrIAPUnsupportedJWTSigningMethod represents a "IAPUnsupportedJWTSigningMethod" kind of error.
|
||||
type ErrIAPUnsupportedJWTSigningMethod struct {
|
||||
Method string
|
||||
}
|
||||
|
||||
// IsErrIAPUnsupportedJWTSigningMethod checks if an error is a ErrIAPUnsupportedJWTSigningMethod.
|
||||
func IsErrIAPUnsupportedJWTSigningMethod(err error) bool {
|
||||
_, ok := err.(ErrIAPUnsupportedJWTSigningMethod)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIAPUnsupportedJWTSigningMethod) Error() string {
|
||||
return fmt.Sprintf("Unsupported JWT signing method: %s", err.Method)
|
||||
}
|
||||
|
||||
// ErrorCodeIAPUnsupportedJWTSigningMethod holds the unique world-error code of this error
|
||||
const ErrorCodeIAPUnsupportedJWTSigningMethod = 12004
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrIAPUnsupportedJWTSigningMethod) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusInternalServerError, Code: ErrorCodeIAPUnsupportedJWTSigningMethod, Message: "Unsupported JWT signing method."}
|
||||
}
|
||||
|
||||
// ErrIAPJWTMissingKID represents a "ErrIAPJWTMissingKID" kind of error.
|
||||
type ErrIAPJWTMissingKID struct {}
|
||||
|
||||
// IsErrIAPUnsupportedJWTSigningMethod checks if an error is a ErrIAPJWTMissingKID.
|
||||
func IsErrIAPJWTMissingKID(err error) bool {
|
||||
_, ok := err.(ErrIAPJWTMissingKID)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrIAPJWTMissingKID) Error() string {
|
||||
return "JWT missing KID"
|
||||
}
|
||||
|
||||
// ErrorCodeErrIAPJWTMissingKID holds the unique world-error code of this error
|
||||
const ErrorCodeErrIAPJWTMissingKID = 12005
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrIAPJWTMissingKID) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusInternalServerError, Code: ErrorCodeErrIAPJWTMissingKID, Message: "JWT missing KID."}
|
||||
}
|
|
@ -0,0 +1,165 @@
|
|||
// 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 identityawareproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const IAPClaimsContextKey string = "iapClaims"
|
||||
|
||||
// IAPClaims represents the claims made by the authentication JWT
|
||||
// passed in by the identity-aware proxy
|
||||
type IAPClaims struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
// Auth provider used to allow auth to get a web.Auth from the IAP provided identity
|
||||
type IAPAuthProvider struct{}
|
||||
|
||||
func init() {
|
||||
auth.RegisterAuthProvider(auth.AuthTypeIAPUser, IAPAuthProvider{})
|
||||
}
|
||||
|
||||
// NewIAPUserJWTAuthtoken generates and signes a new jwt token for a user
|
||||
// These are intentionally short lived because they can be regenerated at
|
||||
// any time from the IAP authn information. They are not related to
|
||||
// session length and are only used to provide user info to the frontend
|
||||
// and a hint to auth.go to retreive auth data from the IAP.
|
||||
func NewIAPUserJWTAuthtoken(u *user.User) (token string, err error) {
|
||||
// Set claims
|
||||
claims := &auth.AuthClaims{
|
||||
Type: auth.AuthTypeIAPUser,
|
||||
UserID: u.ID,
|
||||
UserUsername: u.Username,
|
||||
UserEmail: u.Email,
|
||||
UserName: u.Name,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: time.Now().Add(time.Minute * 5).Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Generate encoded token and send it as response.
|
||||
return t.SignedString([]byte(config.ServiceJWTSecret.GetString()))
|
||||
}
|
||||
branchmispredictor marked this conversation as resolved
Outdated
konrad
commented
Please document this. Please document this.
|
||||
|
||||
// Token generates a local, short-lived JWT based on the identity from the identity-aware proxy.
|
||||
// See also the docs for NewIAPUserJWTAuthtoken
|
||||
branchmispredictor marked this conversation as resolved
Outdated
konrad
commented
Please make errors like this into custom error types which you'd then return. Please make errors like this into custom error types which you'd then return.
|
||||
// @Summary Authenticate a user from the Identity-Aware Proxy
|
||||
// @Description Generates a short-lived JWT based on the identity from the identity-aware proxy in order to provide the front-end with user id and username info
|
||||
// @tags auth
|
||||
// @Accept N/A
|
||||
// @Produce json
|
||||
// @Success 200 {object} auth.Token
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /auth/identityawareproxy/token [get]
|
||||
konrad
commented
Is there a special reason this is a separate endpoint instead of reusing the existing token one? It seems like this will require a frontend change? Is there a special reason this is a separate endpoint instead of reusing the existing token one? It seems like this will require a frontend change?
branchmispredictor
commented
There's a problem with bootstrapping auth here. The way IAPs work is by setting an http header with claims to the downstream service (vikunja-api), however javascript or front-end code does not have access to http headers. So on initial load, there is no cookie / vikunja-jwt set so the frontend does not see us as logged in. Similarly, the existing There's a problem with bootstrapping auth here. The way IAPs work is by setting an http header with claims to the downstream service (vikunja-api), however javascript or front-end code does not have access to http headers.
So on initial load, there is no cookie / vikunja-jwt set so the frontend does not see us as logged in. Similarly, the existing `/user/token` endpoint is also unavailable because it is behind the jwt middleware and requires a jwt cookie set. So this endpoint exists outside of the jwt middleware so it can be accessed from before a user is "logged in"
konrad
commented
So this is the endpoint to "translate" the http header from the IAP to a Vikunja token? Wouldn't it be possible to just extend the existing middleware to look for that http header? All that'd be left then would be to extend the frontend so it somehow checks if the user is logged in (maybe by calling So this is the endpoint to "translate" the http header from the IAP to a Vikunja token? Wouldn't it be possible to just extend the existing middleware to look for that http header? All that'd be left then would be to extend the frontend so it somehow checks if the user is logged in (maybe by calling `/api/v1/user` and see if there's a 200 response coming back and then setting the frontend state to "logged in"?)
branchmispredictor
commented
Was writing that before I saw the reply. Yes, this basically translates the http header from the IAP to a vikunja token. The only problem is that the jwt middleware will error out for /api/v1/user/* if the frontend does not provide an already valid jwt as a cookie ~~What I can do, is abtract this away a bit and make this an `/auth/externalprovider/loggedin` endpoint, so any future external auth source might also reuse the same endpoint.~~
Was writing that before I saw the reply. Yes, this basically translates the http header from the IAP to a vikunja token. The only problem is that the jwt middleware will error out for /api/v1/user/* if the frontend does not provide an already valid jwt as a cookie
konrad
commented
How would that look in the frontend, would it call that endpoint every time on reload? How would that look in the frontend, would it call that endpoint every time on reload?
branchmispredictor
commented
Yes, or at least if not already logged in / if current jwt token is expired. Essentially it's similar to an open-id auth from the frontend's point of view except it 1) is hitting vikunja-backend directly instead of first hitting an external service and 2) it is automatically calling the endpoint instead of waiting for the openid button to be hit. We can actually make num 2 optional, if for whatever reason we prefer being able to have both IAP login and local/openid logins too. Yes, or at least if not already logged in / if current jwt token is expired. Essentially it's similar to an open-id auth from the frontend's point of view except it 1) is hitting vikunja-backend directly instead of first hitting an external service and 2) it is automatically calling the endpoint instead of waiting for the openid button to be hit.
We can actually make num 2 optional, if for whatever reason we prefer being able to have both IAP login and local/openid logins too.
konrad
commented
Then I'd prefer modifying the existing jwt middleware to accept a jwt token or an IAP header if no token is provided (and move all the logic to create a new user etc there as well). That way, we won't need the new endpoint and can modify the frontend only a bit to make a call to Then I'd prefer modifying the existing jwt middleware to accept a jwt token or an IAP header if no token is provided (and move all the logic to create a new user etc there as well). That way, we won't need the new endpoint and can modify the frontend only a bit to make a call to `/user` every time it is loaded, regardless if it has a jwt token in localstorage or not. I think that would make the IAP a lot more transparent to the frontend.
branchmispredictor
commented
Okay, I'll give it a shot. Okay, I'll give it a shot.
branchmispredictor
commented
Following up just to make sure I capture the implicit decisions here, so you want for the backend:
In the frontend:
Following up just to make sure I capture the implicit decisions here, so you want for the backend:
1. If a valid jwt token is presented from the frontend, use that (e.g. ignore the IAP header)
2. If no valid jwt token exists, create one from the IAP header. In the backend, treat this newly generated jwt token the same as if it was provided by the frontend
In the frontend:
1. Call /user/token on load -> will get the jwt token generated from the IAP header
konrad
commented
Almost, I wouldn't generate a jwt token from the backend at all but instead use the IAP header to authenticate the user. That would look like this in the middleware:
The frontend would then always call Almost, I wouldn't generate a jwt token from the backend at all but instead use the IAP header to authenticate the user.
That would look like this in the middleware:
1. If a valid jwt token is presented from the frontend, use that
2. If a valid IAP header is present, use that as a means to authenticate the user in place of a jwt token. This includes creating the user internally if none exists yet. There may be a few places which would need to be a bit more abstracted to not only rely on a jwt token to get the user information.
The frontend would then always call [`user`](https://try.vikunja.io/api/v1/docs#tag/user/paths/~1user/get) to figure out if the user is authenticated or not. Currently it only does that if a jwt token is present in local storage.
|
||||
func GetToken(c echo.Context) error {
|
||||
cl := c.Get(IAPClaimsContextKey).(*IAPClaims)
|
||||
konrad
commented
What if there's more than one Key? Is that even possible? In that case, should we still just use the first one or return a different error? What if there's more than one Key? Is that even possible? In that case, should we still just use the first one or return a different error?
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Check if we have seen this user before
|
||||
u, err := auth.GetOrCreateUserFromExternalAuth(s, cl.Issuer, cl.Subject, cl.Email, cl.Name, cl.PreferredUsername)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.Commit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create token
|
||||
userToken, err := NewIAPUserJWTAuthtoken(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return auth.NewTokenResponse(userToken, c)
|
||||
}
|
||||
|
||||
// Get a web.Auth object from the identity that the IAP provides
|
||||
func (p IAPAuthProvider) GetWebAuth(c echo.Context, authClaims *auth.AuthClaims) (web.Auth, error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Get the user from the IAP identity
|
||||
cl := c.Get(IAPClaimsContextKey).(*IAPClaims)
|
||||
u, err := auth.GetOrCreateUserFromExternalAuth(s, cl.Issuer, cl.Subject, cl.Email, cl.Name, cl.PreferredUsername)
|
||||
branchmispredictor marked this conversation as resolved
Outdated
branchmispredictor
commented
This is almost an exact copy from the method in This is almost an exact copy from the method in `openid.go`. I'll try to refactor into some common helper, but I'm not too familiar with go and how to get them to work with both different claim structs. Open to suggestions here.
konrad
commented
Maybe just have the parameters of the claim like Maybe just have the parameters of the claim like `Issuer`, `Subject`, `Username` and so on as function parameters? Or a separate `createUserOpts` struct or something like that.
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.Commit()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sanity check that the user the frontend thinks it has (the authClaims from the JWT it passed in)
|
||||
// is the same as the user provided by the IAP.
|
||||
if authClaims.UserID != u.ID {
|
||||
return nil, ErrIAPUserFrontendMismatch{}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Validates the claims in the jwt
|
||||
// Matches the jwt-go Claims interface
|
||||
func (c *IAPClaims) Valid() error {
|
||||
// Validate that expiresAt and issuedAt are set and valid (with up to 1 minute of skew)
|
||||
now := TimeFunc()
|
||||
skew := time.Minute
|
||||
if c.VerifyExpiresAt(now.Add(-skew).Unix(), true) == false {
|
||||
delta := now.Sub(time.Unix(c.ExpiresAt, 0))
|
||||
return fmt.Errorf("token is expired by %v", delta)
|
||||
konrad
commented
All errors should have a proper type in All errors should have a proper type in `errors.go`. Maybe you could add a new error type `ErrorIAPClaimsInvalid` and then return that from here for all errors? That would avoid creating 5 different error types for basically the same error.
|
||||
}
|
||||
if c.VerifyIssuedAt(now.Add(skew).Unix(), true) == false {
|
||||
return fmt.Errorf("token used before issued")
|
||||
|
||||
}
|
||||
|
||||
// Validate that subject, email, and issuer are all set
|
||||
if c.Subject == "" {
|
||||
return fmt.Errorf("token missing subject")
|
||||
}
|
||||
if c.Email == "" {
|
||||
return fmt.Errorf("token missing email")
|
||||
}
|
||||
if c.Issuer == "" {
|
||||
return fmt.Errorf("token missing issuer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
// 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 identityawareproxy
|
||||
|
||||
// TODO test GetWebAuth()
|
||||
konrad
commented
🙂 🙂
|
|
@ -0,0 +1,132 @@
|
|||
// 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 identityawareproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lestrrat-go/jwx/jwk"
|
||||
)
|
||||
|
||||
// TimeFunc provides the current time to help validate "exp" and "iss" claims in a JWT.
|
||||
// It is overridden in unit tests
|
||||
var TimeFunc = time.Now
|
||||
|
||||
// Caches the public keys of the identity-aware proxy used to validate the auth data it sends
|
||||
type iapCache struct {
|
||||
konrad
commented
Please use the Please use the `keyvalue` package for this.
|
||||
keyset *jwk.Set
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// GetKeyset returns the cached public keys from the identity-aware proxy
|
||||
// or fetches them for the first time.
|
||||
func (cache *iapCache) GetKeyset() (*jwk.Set, error) {
|
||||
if cache.keyset != nil {
|
||||
return cache.keyset, nil
|
||||
}
|
||||
|
||||
cache.mutex.Lock()
|
||||
defer cache.mutex.Unlock()
|
||||
|
||||
// Check that another thread has not fetched the keyset
|
||||
if cache.keyset != nil {
|
||||
return cache.keyset, nil
|
||||
}
|
||||
|
||||
// Fetch the public key(s) from the identity-aware proxy
|
||||
keyset, err := jwk.FetchHTTP(config.AuthIdentityAwareProxyJwksUri.GetString())
|
||||
if err != nil {
|
||||
log.Error("Failed to retrive the identity-aware proxy's signing public key at URL %s: %v", config.AuthIdentityAwareProxyJwksUri.GetString(), err)
|
||||
return nil, ErrIAPPublicKeysetMissing{Url: config.AuthIdentityAwareProxyJwksUri.GetString()}
|
||||
}
|
||||
cache.keyset = keyset
|
||||
return cache.keyset, nil
|
||||
}
|
||||
|
||||
// The identity-aware proxy authentication middleware parses and validates the
|
||||
// JWT provided by the IAP
|
||||
func Middleware() echo.MiddlewareFunc {
|
||||
cache := &iapCache{}
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
token := c.Request().Header.Get(config.AuthIdentityAwareProxyJwtHeader.GetString())
|
||||
if token == "" {
|
||||
err := ErrIAPTokenMissing{Header: config.AuthIdentityAwareProxyJwtHeader.GetString()}
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
keyset, err := cache.GetKeyset()
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
cl, err := parseAndValidateJwt(token, keyset)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
c.Set(IAPClaimsContextKey, cl)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseAndValidateJwt(token string, keyset *jwk.Set) (*IAPClaims, error) {
|
||||
// Parse the jwt from the identity-aware proxy using the correct key
|
||||
tken, err := jwt.ParseWithClaims(token, &IAPClaims{}, func(unvalidatedToken *jwt.Token) (interface{}, error) {
|
||||
// Only support either ECDSA or RSA signing methods. Never support the "none" signing method
|
||||
if _, ok := unvalidatedToken.Method.(*jwt.SigningMethodECDSA); !ok {
|
||||
if _, ok := unvalidatedToken.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, ErrIAPUnsupportedJWTSigningMethod{Method: unvalidatedToken.Header["alg"].(string)}
|
||||
}
|
||||
}
|
||||
|
||||
keyID, ok := unvalidatedToken.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, ErrIAPJWTMissingKID{}
|
||||
}
|
||||
|
||||
keys := keyset.LookupKeyID(keyID)
|
||||
if len(keys) != 1 {
|
||||
return nil, ErrIAPJWTMissingKID{}
|
||||
}
|
||||
|
||||
var rawkey interface{} // This is the raw key, like *rsa.PublicKey or *ecdsa.PublicKey
|
||||
if err := keys[0].Raw(&rawkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return jwk.PublicKeyOf(rawkey)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cl, ok := tken.Claims.(*IAPClaims)
|
||||
if !ok || !tken.Valid {
|
||||
return nil, fmt.Errorf("failed to parse the jwt claims")
|
||||
}
|
||||
|
||||
return cl, nil
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
// 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 identityawareproxy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/lestrrat-go/jwx/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
/* Valid token with kid "sig-1606406403" and the following claims:
|
||||
{
|
||||
"aud": "vikunja-url",
|
||||
"email": "test@example.com",
|
||||
"exp": 1000,
|
||||
"iat": 1,
|
||||
"iss": "proxy-url",
|
||||
"sub": "12345",
|
||||
"user": "12345"
|
||||
}*/
|
||||
var validToken = "eyJhbGciOiJFUzI1NiIsImtpZCI6InNpZy0xNjA2NDA2NDAzIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ2aWt1bmphLXVybCIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTAwMCwiaWF0IjoxLCJpc3MiOiJwcm94eS11cmwiLCJzdWIiOiIxMjM0NSIsInVzZXIiOiIxMjM0NSJ9.Shh0wxVaojGV2U2FQpgWgMjvU8QbjQSZcN062Qd-WShyyPG_vZsJwbBV6EVM5v_HdN2uMJN0HtEELEPBEM7Hiw"
|
||||
|
||||
var validJwks = `{"keys":[{
|
||||
"crv":"P-256",
|
||||
"kty":"EC",
|
||||
"use":"sig",
|
||||
"kid": "sig-1606406403",
|
||||
"x":"vejjjK-FfBOLU_Vz0t12dx1zmfdR2GseEnverHOKJKk",
|
||||
"y":"09pkYKAr51w8-k5s37_M9oBDU8nI4ALkLufTEumO-r4",
|
||||
"alg": "ES256"
|
||||
}]}`
|
||||
|
||||
// Override time value for tests. Restore default value after.
|
||||
func at(t time.Time, f func()) {
|
||||
TimeFunc = func() time.Time {
|
||||
return t
|
||||
}
|
||||
f()
|
||||
TimeFunc = time.Now
|
||||
}
|
||||
|
||||
func TestParseAndValidateJwt(t *testing.T) {
|
||||
// Run the test at a valid time for the JWT expiration
|
||||
at(time.Unix(50, 0), func() {
|
||||
t.Run("valid jwt and key within expiration", func(t *testing.T) {
|
||||
validKeySet, _ := jwk.ParseString(validJwks)
|
||||
cl, err := parseAndValidateJwt(validToken, validKeySet)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &IAPClaims{
|
||||
Email: "test@example.com",
|
||||
Name: "",
|
||||
PreferredUsername: "",
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "vikunja-url",
|
||||
ExpiresAt: 1000,
|
||||
Id: "",
|
||||
IssuedAt: 1,
|
||||
Issuer: "proxy-url",
|
||||
NotBefore: 0,
|
||||
Subject: "12345",
|
||||
},
|
||||
}, cl)
|
||||
})
|
||||
})
|
||||
|
||||
// Run the test within the skew of the the expiration
|
||||
at(time.Unix(1059, 0), func() {
|
||||
t.Run("valid jwt and key past expiration within skew", func(t *testing.T) {
|
||||
validKeySet, _ := jwk.ParseString(validJwks)
|
||||
cl, err := parseAndValidateJwt(validToken, validKeySet)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, &IAPClaims{
|
||||
Email: "test@example.com",
|
||||
Name: "",
|
||||
PreferredUsername: "",
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "vikunja-url",
|
||||
ExpiresAt: 1000,
|
||||
Id: "",
|
||||
IssuedAt: 1,
|
||||
Issuer: "proxy-url",
|
||||
NotBefore: 0,
|
||||
Subject: "12345",
|
||||
},
|
||||
}, cl)
|
||||
})
|
||||
})
|
||||
|
||||
// Run the test outside the skew of the the expiration
|
||||
at(time.Unix(1061, 0), func() {
|
||||
t.Run("expired jwt", func(t *testing.T) {
|
||||
validKeySet, _ := jwk.ParseString(validJwks)
|
||||
cl, err := parseAndValidateJwt(validToken, validKeySet)
|
||||
assert.Nil(t, cl)
|
||||
assert.EqualError(t, err, "token is expired by 1m1s")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("missing key", func(t *testing.T) {
|
||||
keySet, _ := jwk.ParseString(`{"keys":[{
|
||||
"crv":"P-256",
|
||||
"kty":"EC",
|
||||
"use":"sig",
|
||||
"kid": "non-matching-sig",
|
||||
"x":"vejjjK-FfBOLU_Vz0t12dx1zmfdR2GseEnverHOKJKk",
|
||||
"y":"09pkYKAr51w8-k5s37_M9oBDU8nI4ALkLufTEumO-r4",
|
||||
"alg": "ES256"
|
||||
}]}`)
|
||||
cl, err := parseAndValidateJwt(validToken, keySet)
|
||||
assert.EqualError(t, err, "JWT missing KID")
|
||||
assert.Nil(t, cl)
|
||||
})
|
||||
}
|
|
@ -14,7 +14,7 @@
|
|||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package openid
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
|
@ -26,14 +26,11 @@ import (
|
|||
"code.vikunja.io/web/handler"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
@ -147,7 +144,7 @@ func HandleCallback(c echo.Context) error {
|
|||
defer s.Close()
|
||||
|
||||
// Check if we have seen this user before
|
||||
u, err := getOrCreateUser(s, cl, idToken.Issuer, idToken.Subject)
|
||||
u, err := auth.GetOrCreateUserFromExternalAuth(s, idToken.Issuer, idToken.Subject, cl.Email, cl.Name, cl.PreferredUsername)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
|
||||
|
@ -162,74 +159,3 @@ func HandleCallback(c echo.Context) error {
|
|||
// Create token
|
||||
return auth.NewUserAuthTokenResponse(u, c)
|
||||
}
|
||||
|
||||
func getOrCreateUser(s *xorm.Session, cl *claims, issuer, subject string) (u *user.User, err error) {
|
||||
// Check if the user exists for that issuer and subject
|
||||
u, err = user.GetUserWithEmail(s, &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(s, 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(s, uu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// And create its namespace
|
||||
err = models.CreateNewNamespaceForUser(s, 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 || cl.Name != u.Name {
|
||||
if cl.Email != u.Email {
|
||||
u.Email = cl.Email
|
||||
}
|
||||
if cl.Name != u.Name {
|
||||
u.Name = cl.Name
|
||||
}
|
||||
u, err = user.UpdateUser(s, &user.User{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Name: u.Name,
|
||||
Issuer: issuer,
|
||||
Subject: subject,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
@ -20,8 +20,8 @@ import (
|
|||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
user2 "code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
@ -55,7 +55,7 @@ func (mw *MigrationWeb) Migrate(c echo.Context) error {
|
|||
ms := mw.MigrationStruct()
|
||||
|
||||
// Get the user from context
|
||||
user, err := user2.GetCurrentUser(c)
|
||||
user, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ func (mw *MigrationWeb) Migrate(c echo.Context) error {
|
|||
func (mw *MigrationWeb) Status(c echo.Context) error {
|
||||
ms := mw.MigrationStruct()
|
||||
|
||||
user, err := user2.GetCurrentUser(c)
|
||||
user, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"code.vikunja.io/api/pkg/files"
|
||||
"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/avatar"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/empty"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/gravatar"
|
||||
|
@ -123,7 +124,7 @@ func UploadAvatar(c echo.Context) (err error) {
|
|||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
uc, err := user.GetCurrentUser(c)
|
||||
uc, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
|
|
@ -51,8 +51,9 @@ type vikunjaInfos struct {
|
|||
}
|
||||
|
||||
type authInfo struct {
|
||||
Local localAuthInfo `json:"local"`
|
||||
OpenIDConnect openIDAuthInfo `json:"openid_connect"`
|
||||
Local localAuthInfo `json:"local"`
|
||||
OpenIDConnect openIDAuthInfo `json:"openid_connect"`
|
||||
IdentityAwareProxy identityAwareProxyAuthInfo `json:"identity_aware_proxy"`
|
||||
}
|
||||
|
||||
type localAuthInfo struct {
|
||||
|
@ -65,6 +66,10 @@ type openIDAuthInfo struct {
|
|||
Providers []*openid.Provider `json:"providers"`
|
||||
}
|
||||
|
||||
type identityAwareProxyAuthInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type legalInfo struct {
|
||||
ImprintURL string `json:"imprint_url"`
|
||||
PrivacyPolicyURL string `json:"privacy_policy_url"`
|
||||
|
@ -101,6 +106,9 @@ func Info(c echo.Context) error {
|
|||
Enabled: config.AuthOpenIDEnabled.GetBool(),
|
||||
RedirectURL: config.AuthOpenIDRedirectURL.GetString(),
|
||||
},
|
||||
IdentityAwareProxy: identityAwareProxyAuthInfo{
|
||||
Enabled: config.AuthIdentityAwareProxyEnabled.GetBool(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ import (
|
|||
"xorm.io/xorm"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
@ -54,7 +54,7 @@ func GetListsByNamespaceID(c echo.Context) error {
|
|||
}
|
||||
|
||||
// Get the lists
|
||||
doer, err := user.GetCurrentUser(c)
|
||||
doer, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ func getNamespace(s *xorm.Session, c echo.Context) (namespace *models.Namespace,
|
|||
}
|
||||
|
||||
// Check if the user has acces to that namespace
|
||||
u, err := user.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -25,7 +25,6 @@ import (
|
|||
"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"
|
||||
)
|
||||
|
||||
|
@ -94,39 +93,13 @@ func Login(c echo.Context) error {
|
|||
// @Failure 400 {object} models.Message "Only user token are available for renew."
|
||||
// @Router /user/token [post]
|
||||
func RenewToken(c echo.Context) (err error) {
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
jwtinf := c.Get("user").(*jwt.Token)
|
||||
claims := jwtinf.Claims.(jwt.MapClaims)
|
||||
typ := int(claims["type"].(float64))
|
||||
if typ == auth.AuthTypeLinkShare {
|
||||
share := &models.LinkSharing{}
|
||||
share.ID = int64(claims["id"].(float64))
|
||||
err := share.ReadOne(s, share)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
t, err := auth.NewLinkShareJWTAuthtoken(share)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
return c.JSON(http.StatusOK, auth.Token{Token: t})
|
||||
}
|
||||
|
||||
u, err := user2.GetUserFromClaims(claims)
|
||||
newToken, err := auth.RenewToken(s, c)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
user, err := user2.GetUserWithEmail(s, &user2.User{ID: u.ID})
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
|
@ -134,6 +107,5 @@ func RenewToken(c echo.Context) (err error) {
|
|||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
// Create token
|
||||
return auth.NewUserAuthTokenResponse(user, c)
|
||||
return auth.NewTokenResponse(newToken, c)
|
||||
}
|
||||
|
|
|
@ -20,16 +20,15 @@ import (
|
|||
"fmt"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// CheckToken checks prints a message if the token is valid or not. Currently only used for testing pourposes.
|
||||
func CheckToken(c echo.Context) error {
|
||||
auth, err := auth.GetAuthFromClaims(c)
|
||||
|
||||
user := c.Get("user").(*jwt.Token)
|
||||
|
||||
fmt.Println(user.Valid)
|
||||
fmt.Println(auth != nil && err == nil)
|
||||
|
||||
return c.JSON(418, models.Message{Message: "🍵"})
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import (
|
|||
"code.vikunja.io/api/pkg/db"
|
||||
|
||||
"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/labstack/echo/v4"
|
||||
|
@ -60,7 +61,7 @@ type UserSettings struct {
|
|||
// @Router /user/settings/avatar [get]
|
||||
func GetUserAvatarProvider(c echo.Context) error {
|
||||
|
||||
u, err := user2.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -103,7 +104,7 @@ func ChangeUserAvatarProvider(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, "Bad avatar type provided.")
|
||||
}
|
||||
|
||||
u, err := user2.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -151,7 +152,7 @@ func UpdateGeneralUserSettings(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, "Bad user name provided.")
|
||||
}
|
||||
|
||||
u, err := user2.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import (
|
|||
|
||||
"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"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -44,7 +45,7 @@ import (
|
|||
// @Failure 500 {object} models.Message "Internal server error."
|
||||
// @Router /user/settings/totp/enroll [post]
|
||||
func UserTOTPEnroll(c echo.Context) error {
|
||||
u, err := user.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -81,7 +82,7 @@ func UserTOTPEnroll(c echo.Context) error {
|
|||
// @Failure 500 {object} models.Message "Internal server error."
|
||||
// @Router /user/settings/totp/enable [post]
|
||||
func UserTOTPEnable(c echo.Context) error {
|
||||
u, err := user.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -137,7 +138,7 @@ func UserTOTPDisable(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid model provided.")
|
||||
}
|
||||
|
||||
u, err := user.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -182,7 +183,7 @@ func UserTOTPDisable(c echo.Context) error {
|
|||
// @Failure 500 {object} models.Message "Internal server error."
|
||||
// @Router /user/settings/totp/qrcode [get]
|
||||
func UserTOTPQrCode(c echo.Context) error {
|
||||
u, err := user.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
@ -222,7 +223,7 @@ func UserTOTPQrCode(c echo.Context) error {
|
|||
// @Failure 500 {object} models.Message "Internal server error."
|
||||
// @Router /user/settings/totp [get]
|
||||
func UserTOTP(c echo.Context) error {
|
||||
u, err := user.GetCurrentUser(c)
|
||||
u, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import (
|
|||
|
||||
"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"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -53,7 +54,7 @@ func UpdateUserEmail(c echo.Context) (err error) {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid model provided.")
|
||||
}
|
||||
|
||||
emailUpdate.User, err = user.GetCurrentUser(c)
|
||||
emailUpdate.User, err = auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import (
|
|||
"code.vikunja.io/api/pkg/db"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -48,7 +49,7 @@ type UserPassword struct {
|
|||
// @Router /user/password [post]
|
||||
func UserChangePassword(c echo.Context) error {
|
||||
// Check if the user is itself
|
||||
doer, err := user.GetCurrentUser(c)
|
||||
doer, err := auth.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Error getting current user.")
|
||||
}
|
||||
|
|
|
@ -55,6 +55,7 @@ import (
|
|||
"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/identityawareproxy"
|
||||
"code.vikunja.io/api/pkg/modules/auth/openid"
|
||||
"code.vikunja.io/api/pkg/modules/background"
|
||||
backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler"
|
||||
|
@ -239,11 +240,6 @@ func registerAPIRoutes(a *echo.Group) {
|
|||
n.POST("/auth/openid/:provider/callback", openid.HandleCallback)
|
||||
}
|
||||
|
||||
// Testing
|
||||
if config.ServiceTestingtoken.GetString() != "" {
|
||||
n.PATCH("/test/:table", apiv1.HandleTesting)
|
||||
}
|
||||
|
||||
// Info endpoint
|
||||
n.GET("/info", apiv1.Info)
|
||||
|
||||
|
@ -255,9 +251,19 @@ func registerAPIRoutes(a *echo.Group) {
|
|||
n.POST("/shares/:share/auth", apiv1.AuthenticateLinkShare)
|
||||
}
|
||||
|
||||
// ===== Routes with Authetication =====
|
||||
// Authetification
|
||||
a.Use(middleware.JWT([]byte(config.ServiceJWTSecret.GetString())))
|
||||
// Identity-Aware Proxy auth, requires the same iap middleware as authenticated routes
|
||||
if config.AuthIdentityAwareProxyEnabled.GetBool() {
|
||||
m := n.Group("")
|
||||
m.Use(identityawareproxy.Middleware())
|
||||
m.GET("/auth/identityawareproxy/token", identityawareproxy.GetToken)
|
||||
}
|
||||
|
||||
// ===== Routes with Authentication =====
|
||||
// Authentification
|
||||
a.Use(middleware.JWTWithConfig(auth.GetJWTConfig()))
|
||||
if config.AuthIdentityAwareProxyEnabled.GetBool() {
|
||||
a.Use(identityawareproxy.Middleware())
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
setupRateLimit(a, config.RateLimitKind.GetString())
|
||||
|
|
|
@ -32,6 +32,40 @@ var doc = `{
|
|||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/auth/identityawareproxy/login": {
|
||||
"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",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.Token"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/openid/{provider}/callback": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
@ -8581,6 +8615,9 @@ var doc = `{
|
|||
"v1.authInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"identity_aware_proxy": {
|
||||
"$ref": "#/definitions/v1.identityAwareProxyAuthInfo"
|
||||
},
|
||||
"local": {
|
||||
"$ref": "#/definitions/v1.localAuthInfo"
|
||||
},
|
||||
|
@ -8589,6 +8626,17 @@ var doc = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"v1.identityAwareProxyAuthInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.legalInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -15,6 +15,40 @@
|
|||
},
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/auth/identityawareproxy/login": {
|
||||
"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",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.Token"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/openid/{provider}/callback": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
@ -8564,6 +8598,9 @@
|
|||
"v1.authInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"identity_aware_proxy": {
|
||||
"$ref": "#/definitions/v1.identityAwareProxyAuthInfo"
|
||||
},
|
||||
"local": {
|
||||
"$ref": "#/definitions/v1.localAuthInfo"
|
||||
},
|
||||
|
@ -8572,6 +8609,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"v1.identityAwareProxyAuthInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.legalInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -1199,11 +1199,20 @@ definitions:
|
|||
type: object
|
||||
v1.authInfo:
|
||||
properties:
|
||||
identity_aware_proxy:
|
||||
$ref: '#/definitions/v1.identityAwareProxyAuthInfo'
|
||||
local:
|
||||
$ref: '#/definitions/v1.localAuthInfo'
|
||||
openid_connect:
|
||||
$ref: '#/definitions/v1.openIDAuthInfo'
|
||||
type: object
|
||||
v1.identityAwareProxyAuthInfo:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
v1.legalInfo:
|
||||
properties:
|
||||
imprint_url:
|
||||
|
@ -1329,6 +1338,27 @@ paths:
|
|||
summary: User Avatar
|
||||
tags:
|
||||
- user
|
||||
/auth/identityawareproxy/login:
|
||||
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.
|
||||
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
|
||||
/auth/openid/{provider}/callback:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
@ -29,8 +29,6 @@ import (
|
|||
"xorm.io/xorm"
|
||||
|
||||
"code.vikunja.io/web"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
@ -279,29 +277,6 @@ func CheckUserPassword(user *User, password string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the current user based on its jwt token
|
||||
func GetCurrentUser(c echo.Context) (user *User, err error) {
|
||||
jwtinf := c.Get("user").(*jwt.Token)
|
||||
claims := jwtinf.Claims.(jwt.MapClaims)
|
||||
return GetUserFromClaims(claims)
|
||||
}
|
||||
|
||||
// GetUserFromClaims Returns a new user from jwt claims
|
||||
func GetUserFromClaims(claims jwt.MapClaims) (user *User, err error) {
|
||||
userID, ok := claims["id"].(float64)
|
||||
if !ok {
|
||||
return user, ErrCouldNotGetUserID{}
|
||||
}
|
||||
user = &User{
|
||||
ID: int64(userID),
|
||||
Email: claims["email"].(string),
|
||||
Username: claims["username"].(string),
|
||||
Name: claims["name"].(string),
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// UpdateUser updates a user
|
||||
func UpdateUser(s *xorm.Session, user *User) (updatedUser *User, err error) {
|
||||
|
||||
|
|
I think this should return a
*user.User
since the external auth is still represented as a user in Vikunja. It's not a "new type of authentication" like link share is.