api/pkg/modules/auth/auth.go

312 lines
9.2 KiB
Go

// 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 <https://www.gnu.org/licenses/>.
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)
}
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 _, ok := authProviders[claims.Type]; ok {
return "", echo.NewHTTPError(http.StatusBadRequest, models.Message{Message: "External auth types do not use JWT tokens."})
}
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
}