Handle setting and clearing api key

This commit is contained in:
WithoutPants 2026-02-04 16:43:20 +11:00
parent 6cfb249f3b
commit 976c7f0ba6
12 changed files with 224 additions and 82 deletions

View file

@ -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)

View file

@ -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) {

View file

@ -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
}

View file

@ -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
}

View file

@ -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()

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -3,6 +3,7 @@ package models
type User struct {
Username string
Roles Roles
ApiKey string
}
type UserInput struct {

View file

@ -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
}

View file

@ -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{

View file

@ -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
}
}