mirror of
https://github.com/stashapp/stash.git
synced 2026-02-08 00:12:55 +01:00
Handle setting and clearing api key
This commit is contained in:
parent
6cfb249f3b
commit
976c7f0ba6
12 changed files with 224 additions and 82 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package models
|
|||
type User struct {
|
||||
Username string
|
||||
Roles Roles
|
||||
ApiKey string
|
||||
}
|
||||
|
||||
type UserInput struct {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue