// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details. // // You should have received a copy of the GNU Affero General Public Licensee // along with this program. If not, see . package auth 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" ) // These are all valid auth types type AuthType int const ( AuthTypeUnknown AuthType = iota AuthTypeUser AuthTypeLinkShare AuthTypeIAPUser ) // Key used to store authClaims const AuthClaimsContextKey string = "authClaims" // 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 { GetUser(echo.Context, *AuthClaims) (*user.User, error) RenewToken(echo.Context, *AuthClaims) (string, 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) if err != nil { return err } 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(u *user.User) (token string, err error) { // Set claims 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())) } // NewLinkShareJWTAuthtoken creates a new jwt token from a link share func NewLinkShareJWTAuthtoken(share *models.LinkSharing) (token string, err error) { // Set claims 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 or from an // alternative authProvider func GetAuthFromClaims(c echo.Context) (a web.Auth, err error) { claims := c.Get(AuthClaimsContextKey).(*AuthClaims) if claims.Type == AuthTypeLinkShare && config.ServiceEnableLinkSharing.GetBool() { return getLinkShareFromClaims(claims) } if claims.Type == AuthTypeUser { return getUserFromClaims(claims), nil } if authProvider, ok := authProviders[claims.Type]; ok { return authProvider.GetUser(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) { 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) { claims := c.Get(AuthClaimsContextKey).(*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, share) if err != nil { return "", err } return NewLinkShareJWTAuthtoken(share) } if claims.Type == AuthTypeUser { oldUser := getUserFromClaims(claims) u, err := user.GetUserWithEmail(s, &user.User{ID: oldUser.ID}) if err != nil { return "", err } return NewUserJWTAuthtoken(u) } if authProvider, ok := authProviders[claims.Type]; ok { token, err := authProvider.RenewToken(c, claims) if err != nil { return "", err } return token, nil } return "", echo.NewHTTPError(http.StatusBadRequest, models.Message{Message: "Invalid JWT token."}) } // 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 }