code.gitea.io/gitea@v1.22.3/services/auth/sspi.go (about)

     1  // Copyright 2019 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package auth
     5  
     6  import (
     7  	"context"
     8  	"errors"
     9  	"net/http"
    10  	"strings"
    11  	"sync"
    12  
    13  	"code.gitea.io/gitea/models/auth"
    14  	"code.gitea.io/gitea/models/db"
    15  	user_model "code.gitea.io/gitea/models/user"
    16  	"code.gitea.io/gitea/modules/base"
    17  	"code.gitea.io/gitea/modules/log"
    18  	"code.gitea.io/gitea/modules/optional"
    19  	"code.gitea.io/gitea/modules/setting"
    20  	"code.gitea.io/gitea/modules/web/middleware"
    21  	"code.gitea.io/gitea/services/auth/source/sspi"
    22  	gitea_context "code.gitea.io/gitea/services/context"
    23  
    24  	gouuid "github.com/google/uuid"
    25  )
    26  
    27  const (
    28  	tplSignIn base.TplName = "user/auth/signin"
    29  )
    30  
    31  type SSPIAuth interface {
    32  	AppendAuthenticateHeader(w http.ResponseWriter, data string)
    33  	Authenticate(r *http.Request, w http.ResponseWriter) (userInfo *SSPIUserInfo, outToken string, err error)
    34  }
    35  
    36  var (
    37  	sspiAuth        SSPIAuth // a global instance of the websspi authenticator to avoid acquiring the server credential handle on every request
    38  	sspiAuthOnce    sync.Once
    39  	sspiAuthErrInit error
    40  
    41  	// Ensure the struct implements the interface.
    42  	_ Method = &SSPI{}
    43  )
    44  
    45  // SSPI implements the SingleSignOn interface and authenticates requests
    46  // via the built-in SSPI module in Windows for SPNEGO authentication.
    47  // The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation
    48  // fails (or if negotiation should continue), which would prevent other authentication methods
    49  // to execute at all.
    50  type SSPI struct{}
    51  
    52  // Name represents the name of auth method
    53  func (s *SSPI) Name() string {
    54  	return "sspi"
    55  }
    56  
    57  // Verify uses SSPI (Windows implementation of SPNEGO) to authenticate the request.
    58  // If authentication is successful, returns the corresponding user object.
    59  // If negotiation should continue or authentication fails, immediately returns a 401 HTTP
    60  // response code, as required by the SPNEGO protocol.
    61  func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
    62  	sspiAuthOnce.Do(func() { sspiAuthErrInit = sspiAuthInit() })
    63  	if sspiAuthErrInit != nil {
    64  		return nil, sspiAuthErrInit
    65  	}
    66  	if !s.shouldAuthenticate(req) {
    67  		return nil, nil
    68  	}
    69  
    70  	cfg, err := s.getConfig(req.Context())
    71  	if err != nil {
    72  		log.Error("could not get SSPI config: %v", err)
    73  		return nil, err
    74  	}
    75  
    76  	log.Trace("SSPI Authorization: Attempting to authenticate")
    77  	userInfo, outToken, err := sspiAuth.Authenticate(req, w)
    78  	if err != nil {
    79  		log.Warn("Authentication failed with error: %v\n", err)
    80  		sspiAuth.AppendAuthenticateHeader(w, outToken)
    81  
    82  		// Include the user login page in the 401 response to allow the user
    83  		// to login with another authentication method if SSPI authentication
    84  		// fails
    85  		store.GetData()["Flash"] = map[string]string{
    86  			"ErrorMsg": err.Error(),
    87  		}
    88  		store.GetData()["EnableOpenIDSignIn"] = setting.Service.EnableOpenIDSignIn
    89  		store.GetData()["EnableSSPI"] = true
    90  		// in this case, the Verify function is called in Gitea's web context
    91  		// FIXME: it doesn't look good to render the page here, why not redirect?
    92  		gitea_context.GetWebContext(req).HTML(http.StatusUnauthorized, tplSignIn)
    93  		return nil, err
    94  	}
    95  	if outToken != "" {
    96  		sspiAuth.AppendAuthenticateHeader(w, outToken)
    97  	}
    98  
    99  	username := sanitizeUsername(userInfo.Username, cfg)
   100  	if len(username) == 0 {
   101  		return nil, nil
   102  	}
   103  	log.Info("Authenticated as %s\n", username)
   104  
   105  	user, err := user_model.GetUserByName(req.Context(), username)
   106  	if err != nil {
   107  		if !user_model.IsErrUserNotExist(err) {
   108  			log.Error("GetUserByName: %v", err)
   109  			return nil, err
   110  		}
   111  		if !cfg.AutoCreateUsers {
   112  			log.Error("User '%s' not found", username)
   113  			return nil, nil
   114  		}
   115  		user, err = s.newUser(req.Context(), username, cfg)
   116  		if err != nil {
   117  			log.Error("CreateUser: %v", err)
   118  			return nil, err
   119  		}
   120  	}
   121  
   122  	// Make sure requests to API paths and PWA resources do not create a new session
   123  	if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) {
   124  		handleSignIn(w, req, sess, user)
   125  	}
   126  
   127  	log.Trace("SSPI Authorization: Logged in user %-v", user)
   128  	return user, nil
   129  }
   130  
   131  // getConfig retrieves the SSPI configuration from login sources
   132  func (s *SSPI) getConfig(ctx context.Context) (*sspi.Source, error) {
   133  	sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
   134  		IsActive:  optional.Some(true),
   135  		LoginType: auth.SSPI,
   136  	})
   137  	if err != nil {
   138  		return nil, err
   139  	}
   140  	if len(sources) == 0 {
   141  		return nil, errors.New("no active login sources of type SSPI found")
   142  	}
   143  	if len(sources) > 1 {
   144  		return nil, errors.New("more than one active login source of type SSPI found")
   145  	}
   146  	return sources[0].Cfg.(*sspi.Source), nil
   147  }
   148  
   149  func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) {
   150  	shouldAuth = false
   151  	path := strings.TrimSuffix(req.URL.Path, "/")
   152  	if path == "/user/login" {
   153  		if req.FormValue("user_name") != "" && req.FormValue("password") != "" {
   154  			shouldAuth = false
   155  		} else if req.FormValue("auth_with_sspi") == "1" {
   156  			shouldAuth = true
   157  		}
   158  	} else if middleware.IsAPIPath(req) || isAttachmentDownload(req) {
   159  		shouldAuth = true
   160  	}
   161  	return shouldAuth
   162  }
   163  
   164  // newUser creates a new user object for the purpose of automatic registration
   165  // and populates its name and email with the information present in request headers.
   166  func (s *SSPI) newUser(ctx context.Context, username string, cfg *sspi.Source) (*user_model.User, error) {
   167  	email := gouuid.New().String() + "@localhost.localdomain"
   168  	user := &user_model.User{
   169  		Name:     username,
   170  		Email:    email,
   171  		Language: cfg.DefaultLanguage,
   172  	}
   173  	emailNotificationPreference := user_model.EmailNotificationsDisabled
   174  	overwriteDefault := &user_model.CreateUserOverwriteOptions{
   175  		IsActive:                     optional.Some(cfg.AutoActivateUsers),
   176  		KeepEmailPrivate:             optional.Some(true),
   177  		EmailNotificationsPreference: &emailNotificationPreference,
   178  	}
   179  	if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil {
   180  		return nil, err
   181  	}
   182  
   183  	return user, nil
   184  }
   185  
   186  // stripDomainNames removes NETBIOS domain name and separator from down-level logon names
   187  // (eg. "DOMAIN\user" becomes "user"), and removes the UPN suffix (domain name) and separator
   188  // from UPNs (eg. "user@domain.local" becomes "user")
   189  func stripDomainNames(username string) string {
   190  	if strings.Contains(username, "\\") {
   191  		parts := strings.SplitN(username, "\\", 2)
   192  		if len(parts) > 1 {
   193  			username = parts[1]
   194  		}
   195  	} else if strings.Contains(username, "@") {
   196  		parts := strings.Split(username, "@")
   197  		if len(parts) > 1 {
   198  			username = parts[0]
   199  		}
   200  	}
   201  	return username
   202  }
   203  
   204  func replaceSeparators(username string, cfg *sspi.Source) string {
   205  	newSep := cfg.SeparatorReplacement
   206  	username = strings.ReplaceAll(username, "\\", newSep)
   207  	username = strings.ReplaceAll(username, "/", newSep)
   208  	username = strings.ReplaceAll(username, "@", newSep)
   209  	return username
   210  }
   211  
   212  func sanitizeUsername(username string, cfg *sspi.Source) string {
   213  	if len(username) == 0 {
   214  		return ""
   215  	}
   216  	if cfg.StripDomainNames {
   217  		username = stripDomainNames(username)
   218  	}
   219  	// Replace separators even if we have already stripped the domain name part,
   220  	// as the username can contain several separators: eg. "MICROSOFT\useremail@live.com"
   221  	username = replaceSeparators(username, cfg)
   222  	return username
   223  }