diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index b9ea58e4d..79e48cd42 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -499,8 +499,8 @@ type Mutation { """ configureUISetting(key: String!, value: Any): Map! @hasRole(role: ADMIN) - "Generate and set (or clear) API key" - generateAPIKey(input: GenerateAPIKeyInput!): String! @hasRole(role: ADMIN) + "Generate and set (or clear) API key for the current user" + generateAPIKey(input: GenerateAPIKeyInput!): String! "Returns a link to download the result" exportObjects(input: ExportObjectsInput!): String @hasRole(role: ADMIN) diff --git a/internal/api/authentication.go b/internal/api/authentication.go index aac3b2550..5a7d1e626 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -14,6 +14,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/session" + "github.com/stashapp/stash/pkg/user" ) const ( @@ -31,11 +32,12 @@ func allowUnauthenticated(r *http.Request) bool { return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") } -type UserGetter interface { - GetUser(ctx context.Context, username string) (*models.User, error) +type UserAuthenticator interface { + AuthenticateByAPIKey(ctx context.Context, apiKey string) (*models.User, error) + AuthenticateUserByID(ctx context.Context, username string) (*models.User, error) } -func authenticateHandler(g UserGetter) func(http.Handler) http.Handler { +func authenticateHandler(g UserAuthenticator) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c := config.GetInstance() @@ -47,10 +49,30 @@ func authenticateHandler(g UserGetter) func(http.Handler) http.Handler { return } - userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) + // try to authenticate using api key first + var u *models.User + var err error + ctx := r.Context() + + apiKey := session.GetRequestApiKey(r) + if apiKey != "" { + u, err = g.AuthenticateByAPIKey(ctx, apiKey) + } else { + userID, err := manager.GetInstance().SessionStore.GetSessionUserID(w, r) + if err != nil { + logger.Errorf("error getting session user ID: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if userID != "" { + u, err = g.AuthenticateUserByID(ctx, userID) + } + } + if err != nil { - if !errors.Is(err, session.ErrUnauthorized) { - http.Error(w, err.Error(), http.StatusInternalServerError) + if errors.Is(err, user.ErrInternalError) { + http.Error(w, "internal server error", http.StatusInternalServerError) return } @@ -60,8 +82,6 @@ func authenticateHandler(g UserGetter) func(http.Handler) http.Handler { return } - ctx := r.Context() - if err := session.CheckAllowPublicWithoutAuth(s, c, r); err != nil { var accessErr session.ExternalAccessError if errors.As(err, &accessErr) { @@ -80,20 +100,6 @@ func authenticateHandler(g UserGetter) func(http.Handler) http.Handler { return } - var u *models.User - if userID != "" { - u, err = g.GetUser(ctx, userID) - if err != nil { - // if we can't get the user object, we just return a forbidden error - logger.Errorf("Error getting user object: %v", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - if u == nil { - logger.Errorf("[User] cookie user %q not found", userID) - } - } - if hc := s.LoginRequired(ctx); hc { // authentication is required if u == nil && !allowUnauthenticated(r) { diff --git a/internal/api/directives.go b/internal/api/directives.go index a5d8341e8..646f194af 100644 --- a/internal/api/directives.go +++ b/internal/api/directives.go @@ -17,7 +17,7 @@ func HasRoleDirective(ctx context.Context, obj interface{}, next graphql.Resolve return next(ctx) } - if currentUser != nil && !currentUser.Roles.HasRole(role) { + if !currentUser.Roles.HasRole(role) { return nil, session.ErrUnauthorized } @@ -39,7 +39,8 @@ func IsUserOwnerDirective(ctx context.Context, obj any, next graphql.Resolver) ( return nil, session.ErrUnauthorized } - if currentUser.Username != userObj.Username { + // allow admin access + if !currentUser.Roles.HasRole(models.RoleEnumAdmin) && currentUser.Username != userObj.Username { return nil, session.ErrUnauthorized } diff --git a/internal/api/resolver_model_user.go b/internal/api/resolver_model_user.go index 11919cf64..2c6cc944c 100644 --- a/internal/api/resolver_model_user.go +++ b/internal/api/resolver_model_user.go @@ -17,7 +17,3 @@ func (r *userResolver) Roles(ctx context.Context, obj *models.User) ([]models.Ro } return ret, nil } - -func (r *userResolver) APIKey(ctx context.Context, obj *models.User) (*string, error) { - return nil, nil -} diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 23b61c208..1db1c5587 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -637,29 +637,6 @@ func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDe return makeConfigDefaultsResult(), nil } -func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPIKeyInput) (string, error) { - c := config.GetInstance() - - var newAPIKey string - if input.Clear == nil || !*input.Clear { - username := c.GetUsername() - if username != "" { - var err error - newAPIKey, err = manager.GenerateAPIKey(username) - if err != nil { - return "", err - } - } - } - - c.SetString(config.ApiKey, newAPIKey) - if err := c.Write(); err != nil { - return newAPIKey, err - } - - return newAPIKey, nil -} - func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}, partial map[string]interface{}) (map[string]interface{}, error) { c := config.GetInstance() diff --git a/internal/api/resolver_mutation_user.go b/internal/api/resolver_mutation_user.go index cc48ceef3..dbec308a1 100644 --- a/internal/api/resolver_mutation_user.go +++ b/internal/api/resolver_mutation_user.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/session" @@ -60,3 +61,26 @@ func (r *mutationResolver) ChangeUserPassword(ctx context.Context, input ChangeU return true, nil } + +func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPIKeyInput) (string, error) { + u := session.GetCurrentUser(ctx) + + if u == nil { + return "", fmt.Errorf("no current user in context") + } + + if input.Clear != nil && *input.Clear { + err := r.userService.ClearAPIKey(ctx, u.Username) + if err != nil { + return "", err + } + return "", nil + } + + newAPIKey, err := r.userService.GenerateAPIKey(ctx, u.Username) + if err != nil { + return "", err + } + + return newAPIKey, nil +} diff --git a/internal/manager/config/users.go b/internal/manager/config/users.go index 3b3bdad74..c3d081a71 100644 --- a/internal/manager/config/users.go +++ b/internal/manager/config/users.go @@ -19,6 +19,7 @@ type StoredUser struct { Username string `json:"username" koanf:"username"` PasswordHash string `json:"passwordhash" koanf:"passwordhash"` Roles []models.RoleEnum `json:"roles" koanf:"roles"` + ApiKey string `json:"api_key" koanf:"api_key"` } type UserStore struct { @@ -38,12 +39,14 @@ func (s *Config) GetPasswordHash() string { func (s *UserStore) legacyUser() *StoredUser { un := s.getString(Username) pwHash := s.getString(Password) + apiKey := s.getString(ApiKey) if un != "" && pwHash != "" { return &StoredUser{ Username: un, PasswordHash: pwHash, Roles: []models.RoleEnum{models.RoleEnumAdmin}, + ApiKey: apiKey, } } @@ -80,6 +83,7 @@ func (s *UserStore) convertUser(su StoredUser) *models.User { return &models.User{ Username: su.Username, Roles: su.Roles, + ApiKey: su.ApiKey, } } @@ -167,6 +171,22 @@ func (s *UserStore) ChangeUserPassword(ctx context.Context, username string, new return s.saveUsers() } +func (s *UserStore) ChangeUserAPIKey(ctx context.Context, username string, newAPIKey string) error { + s.Lock() + defer s.Unlock() + + u := s.getUser(username) + if u == nil { + return fmt.Errorf("user not found") + } + + updatedUser := *u + updatedUser.ApiKey = newAPIKey + s.cachedUsers[username] = updatedUser + + return s.saveUsers() +} + func (s *UserStore) CreateUser(ctx context.Context, u models.User, password string) error { s.Lock() defer s.Unlock() @@ -180,6 +200,7 @@ func (s *UserStore) CreateUser(ctx context.Context, u models.User, password stri Username: u.Username, PasswordHash: hashPassword(password), Roles: u.Roles, + ApiKey: u.ApiKey, } s.cachedUsers[u.Username] = newUser @@ -187,6 +208,8 @@ func (s *UserStore) CreateUser(ctx context.Context, u models.User, password stri return s.saveUsers() } +// ReplaceUser replaces an existing user with updated information. +// ApiKey is ignored and not changed by this method. func (s *UserStore) ReplaceUser(ctx context.Context, username string, updated models.User) error { s.Lock() defer s.Unlock() @@ -200,6 +223,8 @@ func (s *UserStore) ReplaceUser(ctx context.Context, username string, updated mo Username: updated.Username, PasswordHash: existingUser.PasswordHash, Roles: updated.Roles, + // don't allow changing apikey with this method + ApiKey: existingUser.ApiKey, } // if username changed, remove old entry diff --git a/internal/manager/repository.go b/internal/manager/repository.go index c71b51daa..866a79787 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -53,10 +53,14 @@ type UserService interface { AllUsers(ctx context.Context) ([]*models.User, error) GetUser(ctx context.Context, username string) (*models.User, error) LoginRequired(ctx context.Context) bool + AuthenticateByAPIKey(ctx context.Context, apiKey string) (*models.User, error) + AuthenticateUserByID(ctx context.Context, username string) (*models.User, error) CreateUser(ctx context.Context, u models.User, password string) error UpdateUser(ctx context.Context, username string, updated models.User) error ChangePassword(ctx context.Context, username, existingPassword, newPassword string) error ChangeUserPassword(ctx context.Context, username string, newPassword string) error + GenerateAPIKey(ctx context.Context, username string) (string, error) + ClearAPIKey(ctx context.Context, username string) error DeleteUser(ctx context.Context, username string) error } diff --git a/pkg/models/model_user.go b/pkg/models/model_user.go index d885382db..fb683656c 100644 --- a/pkg/models/model_user.go +++ b/pkg/models/model_user.go @@ -3,6 +3,7 @@ package models type User struct { Username string Roles Roles + ApiKey string } type UserInput struct { diff --git a/pkg/session/session.go b/pkg/session/session.go index 50de79fbb..dae7ea44f 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -159,10 +159,7 @@ func GetCurrentUser(ctx context.Context) *models.User { return nil } -func (s *Store) Authenticate(w http.ResponseWriter, r *http.Request) (userID string, err error) { - c := s.config - - // translate api key into current user, if present +func GetRequestApiKey(r *http.Request) string { apiKey := r.Header.Get(ApiKeyHeader) // try getting the api key as a query parameter @@ -170,24 +167,5 @@ func (s *Store) Authenticate(w http.ResponseWriter, r *http.Request) (userID str apiKey = r.URL.Query().Get(ApiKeyParameter) } - // FIXME - handle this - if apiKey != "" { - // match against configured API and set userID to the - // configured username. In future, we'll want to - // get the username from the key. - if c.GetAPIKey() != apiKey { - return "", ErrUnauthorized - } - - userID = c.GetUsername() - } else { - // handle session - userID, err = s.GetSessionUserID(w, r) - } - - if err != nil { - return "", err - } - - return + return apiKey } diff --git a/internal/manager/apikey.go b/pkg/user/apikey.go similarity index 93% rename from internal/manager/apikey.go rename to pkg/user/apikey.go index 7bd3126fa..26aaae1db 100644 --- a/internal/manager/apikey.go +++ b/pkg/user/apikey.go @@ -1,4 +1,4 @@ -package manager +package user import ( "errors" @@ -17,7 +17,7 @@ type APIKeyClaims struct { jwt.RegisteredClaims } -func GenerateAPIKey(userID string) (string, error) { +func generateAPIKey(userID string) (string, error) { claims := &APIKeyClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ diff --git a/pkg/user/service.go b/pkg/user/service.go index 295a11eb4..fbfa12fd0 100644 --- a/pkg/user/service.go +++ b/pkg/user/service.go @@ -16,6 +16,7 @@ var ( ErrEmptyUsername = errors.New("empty username") ErrUsernameHasWhitespace = errors.New("username has leading or trailing whitespace") ErrDeleteLastAdminUser = errors.New("final admin user cannot be deleted") + ErrRemoveLastAdminRole = errors.New("final admin role cannot be removed") ErrInternalError = errors.New("internal error") ErrAccessDenied = errors.New("access denied") ErrCurrentPasswordIncorrect = errors.New("current password incorrect") @@ -30,6 +31,7 @@ type UserSource interface { CreateUser(ctx context.Context, u models.User, password string) error ReplaceUser(ctx context.Context, username string, updated models.User) error ChangeUserPassword(ctx context.Context, username string, newPassword string) error + ChangeUserAPIKey(ctx context.Context, username string, newAPIKey string) error DeleteUser(ctx context.Context, username string) error } @@ -50,6 +52,10 @@ func (s *Service) AllUsers(ctx context.Context) ([]*models.User, error) { return s.Store.AllUsers(ctx) } +func userIsLocked(u *models.User) bool { + return len(u.Roles) == 0 +} + func (s *Service) ValidateCredentials(ctx context.Context, username string, password string) error { // ensure user is not locked u, err := s.GetUser(ctx, username) @@ -63,7 +69,7 @@ func (s *Service) ValidateCredentials(ctx context.Context, username string, pass return ErrAccessDenied } - if len(u.Roles) == 0 { + if userIsLocked(u) { logger.Infof("[login attempt] user %s is locked", username) return ErrAccessDenied } @@ -75,6 +81,61 @@ func (s *Service) ValidateCredentials(ctx context.Context, username string, pass return nil } +// AuthenticateUserByID authenticates a user by their username and returns the user object if successful. +// This is used for session-based authentication. +// It will return an error if the user does not exist or if the user is locked. +func (s *Service) AuthenticateUserByID(ctx context.Context, username string) (*models.User, error) { + u, err := s.GetUser(ctx, username) + if err != nil { + logger.Errorf("error getting user for authentication: %v", err) + return nil, ErrInternalError + } + + if u == nil { + logger.Infof("[authentication] user %s not found", username) + return nil, ErrAccessDenied + } + + if userIsLocked(u) { + logger.Infof("[authentication] user %s is locked", username) + return nil, ErrAccessDenied + } + + return u, nil +} + +func (s *Service) AuthenticateByAPIKey(ctx context.Context, apiKey string) (*models.User, error) { + username, err := GetUserIDFromAPIKey(apiKey) + if err != nil { + logger.Errorf("error getting user ID from api key: %v", err) + return nil, ErrInternalError + } + + user, err := s.GetUser(ctx, username) + if err != nil { + logger.Errorf("error getting user by username: %v", err) + return nil, ErrInternalError + } + + if user == nil { + logger.Infof("[apikey authentication] user %s not found", username) + return nil, ErrAccessDenied + } + + if userIsLocked(user) { + logger.Infof("[apikey authentication] user %s is locked", username) + return nil, ErrAccessDenied + } + + // ensure apikey matches + if user.ApiKey != apiKey { + logger.Infof("[apikey authentication] invalid api key for user %s", username) + return nil, ErrAccessDenied + } + + return user, nil +} + func (s *Service) validateUsername(username string) error { if username == "" { return ErrEmptyUsername @@ -173,6 +234,27 @@ func (s *Service) UpdateUser(ctx context.Context, username string, updated model } } + // validate roles + // don't allow removing admin from last admin user + if existingRoles.HasRole(models.RoleEnumAdmin) && !updated.Roles.HasRole(models.RoleEnumAdmin) { + users, err := s.AllUsers(ctx) + if err != nil { + return fmt.Errorf("error getting all users: %w", err) + } + + hasAdmin := false + for _, u := range users { + if u.Username != existingUser.Username && u.Roles.HasRole(models.RoleEnumAdmin) { + hasAdmin = true + break + } + } + + if !hasAdmin { + return ErrRemoveLastAdminRole + } + } + // update user in store if err := s.Store.ReplaceUser(ctx, username, updated); err != nil { return fmt.Errorf("error updating user: %w", err) @@ -225,6 +307,53 @@ func (s *Service) ChangeUserPassword(ctx context.Context, username, newPassword return nil } +func (s *Service) GenerateAPIKey(ctx context.Context, username string) (string, error) { + // check if user exists + existingUser, err := s.GetUser(ctx, username) + if err != nil { + return "", fmt.Errorf("error getting existing user: %w", err) + } + + if existingUser == nil { + return "", ErrUserNotExist + } + + // generate new api key + newAPIKey, err := generateAPIKey(username) + if err != nil { + return "", fmt.Errorf("error generating api key: %w", err) + } + + if err := s.Store.ChangeUserAPIKey(ctx, username, newAPIKey); err != nil { + return "", fmt.Errorf("error updating user with new api key: %w", err) + } + + logger.Infof("[user] generated new API key for %q", username) + + return newAPIKey, nil +} + +func (s *Service) ClearAPIKey(ctx context.Context, username string) error { + // check if user exists + existingUser, err := s.GetUser(ctx, username) + if err != nil { + return fmt.Errorf("error getting existing user: %w", err) + } + + if existingUser == nil { + return ErrUserNotExist + } + + // clear api key + if err := s.Store.ChangeUserAPIKey(ctx, username, ""); err != nil { + return fmt.Errorf("error clearing user api key: %w", err) + } + + logger.Infof("[user] cleared API key for %q", username) + + return nil +} + func (s *Service) DeleteUser(ctx context.Context, username string) error { // check if user exists existingUser, err := s.GetUser(ctx, username) @@ -236,7 +365,7 @@ func (s *Service) DeleteUser(ctx context.Context, username string) error { return ErrUserNotExist } - // don't allow deleting last admin user + // don't allow deleting last admin user unless it is the last user if existingUser.Roles.HasRole(models.RoleEnumAdmin) { users, err := s.AllUsers(ctx) if err != nil { @@ -251,7 +380,8 @@ func (s *Service) DeleteUser(ctx context.Context, username string) error { } } - if !hasAdmin { + // allow deleting last admin if it is the only user + if !hasAdmin && len(users) > 1 { return ErrDeleteLastAdminUser } }