diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2021-06-10 01:53:16 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-06-09 19:53:16 +0200 |
commit | fb3ffeb18df6bb94bb3f69348a93398b05259174 (patch) | |
tree | aa56433e062bc68d2a118581a715ee324f025594 /services/auth | |
parent | da057996d584c633524406d69b424cbc3d4473eb (diff) | |
download | gitea-fb3ffeb18df6bb94bb3f69348a93398b05259174.tar.gz gitea-fb3ffeb18df6bb94bb3f69348a93398b05259174.zip |
Add sso.Group, context.Auth, context.APIAuth to allow auth special routes (#16086)
* Add sso.Group, context.Auth, context.APIAuth to allow auth special routes
* Remove unnecessary check
* Rename sso -> auth
* remove unused method of Auth interface
Diffstat (limited to 'services/auth')
-rw-r--r-- | services/auth/auth.go | 130 | ||||
-rw-r--r-- | services/auth/auth_test.go | 128 | ||||
-rw-r--r-- | services/auth/basic.go | 130 | ||||
-rw-r--r-- | services/auth/group.go | 73 | ||||
-rw-r--r-- | services/auth/interface.go | 39 | ||||
-rw-r--r-- | services/auth/oauth2.go | 144 | ||||
-rw-r--r-- | services/auth/placeholder.go | 9 | ||||
-rw-r--r-- | services/auth/reverseproxy.go | 125 | ||||
-rw-r--r-- | services/auth/session.go | 75 | ||||
-rw-r--r-- | services/auth/sspi_windows.go | 246 |
10 files changed, 1099 insertions, 0 deletions
diff --git a/services/auth/auth.go b/services/auth/auth.go new file mode 100644 index 0000000000..5492a8b74e --- /dev/null +++ b/services/auth/auth.go @@ -0,0 +1,130 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "fmt" + "net/http" + "reflect" + "regexp" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/middleware" +) + +// authMethods contains the list of authentication plugins in the order they are expected to be +// executed. +// +// The OAuth2 plugin is expected to be executed first, as it must ignore the user id stored +// in the session (if there is a user id stored in session other plugins might return the user +// object for that id). +// +// The Session plugin is expected to be executed second, in order to skip authentication +// for users that have already signed in. +var authMethods = []Auth{ + &OAuth2{}, + &Basic{}, + &Session{}, +} + +// The purpose of the following three function variables is to let the linter know that +// those functions are not dead code and are actually being used +var ( + _ = handleSignIn +) + +// Methods returns the instances of all registered methods +func Methods() []Auth { + return authMethods +} + +// Register adds the specified instance to the list of available methods +func Register(method Auth) { + authMethods = append(authMethods, method) +} + +// Init should be called exactly once when the application starts to allow plugins +// to allocate necessary resources +func Init() { + if setting.Service.EnableReverseProxyAuth { + Register(&ReverseProxy{}) + } + specialInit() + for _, method := range Methods() { + err := method.Init() + if err != nil { + log.Error("Could not initialize '%s' auth method, error: %s", reflect.TypeOf(method).String(), err) + } + } +} + +// Free should be called exactly once when the application is terminating to allow Auth plugins +// to release necessary resources +func Free() { + for _, method := range Methods() { + err := method.Free() + if err != nil { + log.Error("Could not free '%s' auth method, error: %s", reflect.TypeOf(method).String(), err) + } + } +} + +// isAttachmentDownload check if request is a file download (GET) with URL to an attachment +func isAttachmentDownload(req *http.Request) bool { + return strings.HasPrefix(req.URL.Path, "/attachments/") && req.Method == "GET" +} + +var gitRawPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|raw/)`) +var lfsPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/info/lfs/`) + +func isGitRawOrLFSPath(req *http.Request) bool { + if gitRawPathRe.MatchString(req.URL.Path) { + return true + } + if setting.LFS.StartServer { + return lfsPathRe.MatchString(req.URL.Path) + } + return false +} + +// handleSignIn clears existing session variables and stores new ones for the specified user object +func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *models.User) { + _ = sess.Delete("openid_verified_uri") + _ = sess.Delete("openid_signin_remember") + _ = sess.Delete("openid_determined_email") + _ = sess.Delete("openid_determined_username") + _ = sess.Delete("twofaUid") + _ = sess.Delete("twofaRemember") + _ = sess.Delete("u2fChallenge") + _ = sess.Delete("linkAccount") + err := sess.Set("uid", user.ID) + if err != nil { + log.Error(fmt.Sprintf("Error setting session: %v", err)) + } + err = sess.Set("uname", user.Name) + if err != nil { + log.Error(fmt.Sprintf("Error setting session: %v", err)) + } + + // Language setting of the user overwrites the one previously set + // If the user does not have a locale set, we save the current one. + if len(user.Language) == 0 { + lc := middleware.Locale(resp, req) + user.Language = lc.Language() + if err := models.UpdateUserCols(user, "language"); err != nil { + log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", user.ID, user.Language)) + return + } + } + + middleware.SetLocaleCookie(resp, user.Language, 0) + + // Clear whatever CSRF has right now, force to generate a new one + middleware.DeleteCSRFCookie(resp) +} diff --git a/services/auth/auth_test.go b/services/auth/auth_test.go new file mode 100644 index 0000000000..f6b43835f4 --- /dev/null +++ b/services/auth/auth_test.go @@ -0,0 +1,128 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/modules/setting" +) + +func Test_isGitRawOrLFSPath(t *testing.T) { + + tests := []struct { + path string + + want bool + }{ + { + "/owner/repo/git-upload-pack", + true, + }, + { + "/owner/repo/git-receive-pack", + true, + }, + { + "/owner/repo/info/refs", + true, + }, + { + "/owner/repo/HEAD", + true, + }, + { + "/owner/repo/objects/info/alternates", + true, + }, + { + "/owner/repo/objects/info/http-alternates", + true, + }, + { + "/owner/repo/objects/info/packs", + true, + }, + { + "/owner/repo/objects/info/blahahsdhsdkla", + true, + }, + { + "/owner/repo/objects/01/23456789abcdef0123456789abcdef01234567", + true, + }, + { + "/owner/repo/objects/pack/pack-123456789012345678921234567893124567894.pack", + true, + }, + { + "/owner/repo/objects/pack/pack-0123456789abcdef0123456789abcdef0123456.idx", + true, + }, + { + "/owner/repo/raw/branch/foo/fanaso", + true, + }, + { + "/owner/repo/stars", + false, + }, + { + "/notowner", + false, + }, + { + "/owner/repo", + false, + }, + { + "/owner/repo/commit/123456789012345678921234567893124567894", + false, + }, + } + lfsTests := []string{ + "/owner/repo/info/lfs/", + "/owner/repo/info/lfs/objects/batch", + "/owner/repo/info/lfs/objects/oid/filename", + "/owner/repo/info/lfs/objects/oid", + "/owner/repo/info/lfs/objects", + "/owner/repo/info/lfs/verify", + "/owner/repo/info/lfs/locks", + "/owner/repo/info/lfs/locks/verify", + "/owner/repo/info/lfs/locks/123/unlock", + } + + origLFSStartServer := setting.LFS.StartServer + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + req, _ := http.NewRequest("POST", "http://localhost"+tt.path, nil) + setting.LFS.StartServer = false + if got := isGitRawOrLFSPath(req); got != tt.want { + t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want) + } + setting.LFS.StartServer = true + if got := isGitRawOrLFSPath(req); got != tt.want { + t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want) + } + }) + } + for _, tt := range lfsTests { + t.Run(tt, func(t *testing.T) { + req, _ := http.NewRequest("POST", tt, nil) + setting.LFS.StartServer = false + if got := isGitRawOrLFSPath(req); got != setting.LFS.StartServer { + t.Errorf("isGitOrLFSPath(%q) = %v, want %v, %v", tt, got, setting.LFS.StartServer, gitRawPathRe.MatchString(tt)) + } + setting.LFS.StartServer = true + if got := isGitRawOrLFSPath(req); got != setting.LFS.StartServer { + t.Errorf("isGitOrLFSPath(%q) = %v, want %v", tt, got, setting.LFS.StartServer) + } + }) + } + setting.LFS.StartServer = origLFSStartServer +} diff --git a/services/auth/basic.go b/services/auth/basic.go new file mode 100644 index 0000000000..0bce4f1d06 --- /dev/null +++ b/services/auth/basic.go @@ -0,0 +1,130 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web/middleware" +) + +// Ensure the struct implements the interface. +var ( + _ Auth = &Basic{} +) + +// Basic implements the Auth interface and authenticates requests (API requests +// only) by looking for Basic authentication data or "x-oauth-basic" token in the "Authorization" +// header. +type Basic struct { +} + +// Name represents the name of auth method +func (b *Basic) Name() string { + return "basic" +} + +// Init does nothing as the Basic implementation does not need to allocate any resources +func (b *Basic) Init() error { + return nil +} + +// Free does nothing as the Basic implementation does not have to release any resources +func (b *Basic) Free() error { + return nil +} + +// Verify extracts and validates Basic data (username and password/token) from the +// "Authorization" header of the request and returns the corresponding user object for that +// name/token on successful validation. +// Returns nil if header is empty or validation fails. +func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User { + // Basic authentication should only fire on API, Download or on Git or LFSPaths + if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isGitRawOrLFSPath(req) { + return nil + } + + baHead := req.Header.Get("Authorization") + if len(baHead) == 0 { + return nil + } + + auths := strings.SplitN(baHead, " ", 2) + if len(auths) != 2 || (auths[0] != "Basic" && auths[0] != "basic") { + return nil + } + + uname, passwd, _ := base.BasicAuthDecode(auths[1]) + + // Check if username or password is a token + isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic" + // Assume username is token + authToken := uname + if !isUsernameToken { + log.Trace("Basic Authorization: Attempting login for: %s", uname) + // Assume password is token + authToken = passwd + } else { + log.Trace("Basic Authorization: Attempting login with username as token") + } + + uid := CheckOAuthAccessToken(authToken) + if uid != 0 { + log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid) + + u, err := models.GetUserByID(uid) + if err != nil { + log.Error("GetUserByID: %v", err) + return nil + } + + store.GetData()["IsApiToken"] = true + return u + } + + token, err := models.GetAccessTokenBySHA(authToken) + if err == nil { + log.Trace("Basic Authorization: Valid AccessToken for user[%d]", uid) + u, err := models.GetUserByID(token.UID) + if err != nil { + log.Error("GetUserByID: %v", err) + return nil + } + + token.UpdatedUnix = timeutil.TimeStampNow() + if err = models.UpdateAccessToken(token); err != nil { + log.Error("UpdateAccessToken: %v", err) + } + + store.GetData()["IsApiToken"] = true + return u + } else if !models.IsErrAccessTokenNotExist(err) && !models.IsErrAccessTokenEmpty(err) { + log.Error("GetAccessTokenBySha: %v", err) + } + + if !setting.Service.EnableBasicAuth { + return nil + } + + log.Trace("Basic Authorization: Attempting SignIn for %s", uname) + u, err := models.UserSignIn(uname, passwd) + if err != nil { + if !models.IsErrUserNotExist(err) { + log.Error("UserSignIn: %v", err) + } + return nil + } + + log.Trace("Basic Authorization: Logged in user %-v", u) + + return u +} diff --git a/services/auth/group.go b/services/auth/group.go new file mode 100644 index 0000000000..b61949de7d --- /dev/null +++ b/services/auth/group.go @@ -0,0 +1,73 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "net/http" + + "code.gitea.io/gitea/models" +) + +// Ensure the struct implements the interface. +var ( + _ Auth = &Group{} +) + +// Group implements the Auth interface with serval Auth. +type Group struct { + methods []Auth +} + +// NewGroup creates a new auth group +func NewGroup(methods ...Auth) *Group { + return &Group{ + methods: methods, + } +} + +// Name represents the name of auth method +func (b *Group) Name() string { + return "group" +} + +// Init does nothing as the Basic implementation does not need to allocate any resources +func (b *Group) Init() error { + for _, m := range b.methods { + if err := m.Init(); err != nil { + return err + } + } + return nil +} + +// Free does nothing as the Basic implementation does not have to release any resources +func (b *Group) Free() error { + for _, m := range b.methods { + if err := m.Free(); err != nil { + return err + } + } + return nil +} + +// Verify extracts and validates +func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User { + if !models.HasEngine { + return nil + } + + // Try to sign in with each of the enabled plugins + for _, ssoMethod := range b.methods { + user := ssoMethod.Verify(req, w, store, sess) + if user != nil { + if store.GetData()["AuthedMethod"] == nil { + store.GetData()["AuthedMethod"] = ssoMethod.Name() + } + return user + } + } + + return nil +} diff --git a/services/auth/interface.go b/services/auth/interface.go new file mode 100644 index 0000000000..a305bdfc22 --- /dev/null +++ b/services/auth/interface.go @@ -0,0 +1,39 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/session" + "code.gitea.io/gitea/modules/web/middleware" +) + +// DataStore represents a data store +type DataStore middleware.DataStore + +// SessionStore represents a session store +type SessionStore session.Store + +// Auth represents an authentication method (plugin) for HTTP requests. +type Auth interface { + Name() string + + // Init should be called exactly once before using any of the other methods, + // in order to allow the plugin to allocate necessary resources + Init() error + + // Free should be called exactly once before application closes, in order to + // give chance to the plugin to free any allocated resources + Free() error + + // Verify tries to verify the authentication data contained in the request. + // If verification is successful returns either an existing user object (with id > 0) + // or a new user object (with id = 0) populated with the information that was found + // in the authentication data (username or email). + // Returns nil if verification fails. + Verify(http *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User +} diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go new file mode 100644 index 0000000000..c6b98c144f --- /dev/null +++ b/services/auth/oauth2.go @@ -0,0 +1,144 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "net/http" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web/middleware" +) + +// Ensure the struct implements the interface. +var ( + _ Auth = &OAuth2{} +) + +// CheckOAuthAccessToken returns uid of user from oauth token +func CheckOAuthAccessToken(accessToken string) int64 { + // JWT tokens require a "." + if !strings.Contains(accessToken, ".") { + return 0 + } + token, err := models.ParseOAuth2Token(accessToken) + if err != nil { + log.Trace("ParseOAuth2Token: %v", err) + return 0 + } + var grant *models.OAuth2Grant + if grant, err = models.GetOAuth2GrantByID(token.GrantID); err != nil || grant == nil { + return 0 + } + if token.Type != models.TypeAccessToken { + return 0 + } + if token.ExpiresAt < time.Now().Unix() || token.IssuedAt > time.Now().Unix() { + return 0 + } + return grant.UserID +} + +// OAuth2 implements the Auth interface and authenticates requests +// (API requests only) by looking for an OAuth token in query parameters or the +// "Authorization" header. +type OAuth2 struct { +} + +// Init does nothing as the OAuth2 implementation does not need to allocate any resources +func (o *OAuth2) Init() error { + return nil +} + +// Name represents the name of auth method +func (o *OAuth2) Name() string { + return "oauth2" +} + +// Free does nothing as the OAuth2 implementation does not have to release any resources +func (o *OAuth2) Free() error { + return nil +} + +// userIDFromToken returns the user id corresponding to the OAuth token. +func (o *OAuth2) userIDFromToken(req *http.Request, store DataStore) int64 { + _ = req.ParseForm() + + // Check access token. + tokenSHA := req.Form.Get("token") + if len(tokenSHA) == 0 { + tokenSHA = req.Form.Get("access_token") + } + if len(tokenSHA) == 0 { + // Well, check with header again. + auHead := req.Header.Get("Authorization") + if len(auHead) > 0 { + auths := strings.Fields(auHead) + if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { + tokenSHA = auths[1] + } + } + } + if len(tokenSHA) == 0 { + return 0 + } + + // Let's see if token is valid. + if strings.Contains(tokenSHA, ".") { + uid := CheckOAuthAccessToken(tokenSHA) + if uid != 0 { + store.GetData()["IsApiToken"] = true + } + return uid + } + t, err := models.GetAccessTokenBySHA(tokenSHA) + if err != nil { + if !models.IsErrAccessTokenNotExist(err) && !models.IsErrAccessTokenEmpty(err) { + log.Error("GetAccessTokenBySHA: %v", err) + } + return 0 + } + t.UpdatedUnix = timeutil.TimeStampNow() + if err = models.UpdateAccessToken(t); err != nil { + log.Error("UpdateAccessToken: %v", err) + } + store.GetData()["IsApiToken"] = true + return t.UID +} + +// Verify extracts the user ID from the OAuth token in the query parameters +// or the "Authorization" header and returns the corresponding user object for that ID. +// If verification is successful returns an existing user object. +// Returns nil if verification fails. +func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User { + if !models.HasEngine { + return nil + } + + if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) { + return nil + } + + id := o.userIDFromToken(req, store) + if id <= 0 { + return nil + } + log.Trace("OAuth2 Authorization: Found token for user[%d]", id) + + user, err := models.GetUserByID(id) + if err != nil { + if !models.IsErrUserNotExist(err) { + log.Error("GetUserByName: %v", err) + } + return nil + } + + log.Trace("OAuth2 Authorization: Logged in user %-v", user) + return user +} diff --git a/services/auth/placeholder.go b/services/auth/placeholder.go new file mode 100644 index 0000000000..50e3061885 --- /dev/null +++ b/services/auth/placeholder.go @@ -0,0 +1,9 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// +build !windows + +package auth + +func specialInit() {} diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go new file mode 100644 index 0000000000..f958d28c9a --- /dev/null +++ b/services/auth/reverseproxy.go @@ -0,0 +1,125 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web/middleware" + + gouuid "github.com/google/uuid" +) + +// Ensure the struct implements the interface. +var ( + _ Auth = &ReverseProxy{} +) + +// ReverseProxy implements the Auth interface, but actually relies on +// a reverse proxy for authentication of users. +// On successful authentication the proxy is expected to populate the username in the +// "setting.ReverseProxyAuthUser" header. Optionally it can also populate the email of the +// user in the "setting.ReverseProxyAuthEmail" header. +type ReverseProxy struct { +} + +// getUserName extracts the username from the "setting.ReverseProxyAuthUser" header +func (r *ReverseProxy) getUserName(req *http.Request) string { + webAuthUser := strings.TrimSpace(req.Header.Get(setting.ReverseProxyAuthUser)) + if len(webAuthUser) == 0 { + return "" + } + return webAuthUser +} + +// Name represents the name of auth method +func (r *ReverseProxy) Name() string { + return "reverse_proxy" +} + +// Init does nothing as the ReverseProxy implementation does not need initialization +func (r *ReverseProxy) Init() error { + return nil +} + +// Free does nothing as the ReverseProxy implementation does not have to release resources +func (r *ReverseProxy) Free() error { + return nil +} + +// Verify extracts the username from the "setting.ReverseProxyAuthUser" header +// of the request and returns the corresponding user object for that name. +// Verification of header data is not performed as it should have already been done by +// the revese proxy. +// If a username is available in the "setting.ReverseProxyAuthUser" header an existing +// user object is returned (populated with username or email found in header). +// Returns nil if header is empty. +func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User { + username := r.getUserName(req) + if len(username) == 0 { + return nil + } + log.Trace("ReverseProxy Authorization: Found username: %s", username) + + user, err := models.GetUserByName(username) + if err != nil { + if !models.IsErrUserNotExist(err) || !r.isAutoRegisterAllowed() { + log.Error("GetUserByName: %v", err) + return nil + } + user = r.newUser(req) + } + + // Make sure requests to API paths, attachment downloads, git and LFS do not create a new session + if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isGitRawOrLFSPath(req) { + if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) { + handleSignIn(w, req, sess, user) + } + } + store.GetData()["IsReverseProxy"] = true + + log.Trace("ReverseProxy Authorization: Logged in user %-v", user) + return user +} + +// isAutoRegisterAllowed checks if EnableReverseProxyAutoRegister setting is true +func (r *ReverseProxy) isAutoRegisterAllowed() bool { + return setting.Service.EnableReverseProxyAutoRegister +} + +// newUser creates a new user object for the purpose of automatic registration +// and populates its name and email with the information present in request headers. +func (r *ReverseProxy) newUser(req *http.Request) *models.User { + username := r.getUserName(req) + if len(username) == 0 { + return nil + } + + email := gouuid.New().String() + "@localhost" + if setting.Service.EnableReverseProxyEmail { + webAuthEmail := req.Header.Get(setting.ReverseProxyAuthEmail) + if len(webAuthEmail) > 0 { + email = webAuthEmail + } + } + + user := &models.User{ + Name: username, + Email: email, + IsActive: true, + } + if err := models.CreateUser(user); err != nil { + // FIXME: should I create a system notice? + log.Error("CreateUser: %v", err) + return nil + } + + return user +} diff --git a/services/auth/session.go b/services/auth/session.go new file mode 100644 index 0000000000..9f08f43363 --- /dev/null +++ b/services/auth/session.go @@ -0,0 +1,75 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" +) + +// Ensure the struct implements the interface. +var ( + _ Auth = &Session{} +) + +// Session checks if there is a user uid stored in the session and returns the user +// object for that uid. +type Session struct { +} + +// Init does nothing as the Session implementation does not need to allocate any resources +func (s *Session) Init() error { + return nil +} + +// Name represents the name of auth method +func (s *Session) Name() string { + return "session" +} + +// Free does nothing as the Session implementation does not have to release any resources +func (s *Session) Free() error { + return nil +} + +// Verify checks if there is a user uid stored in the session and returns the user +// object for that uid. +// Returns nil if there is no user uid stored in the session. +func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User { + user := SessionUser(sess) + if user != nil { + return user + } + return nil +} + +// SessionUser returns the user object corresponding to the "uid" session variable. +func SessionUser(sess SessionStore) *models.User { + // Get user ID + uid := sess.Get("uid") + if uid == nil { + return nil + } + log.Trace("Session Authorization: Found user[%d]", uid) + + id, ok := uid.(int64) + if !ok { + return nil + } + + // Get user object + user, err := models.GetUserByID(id) + if err != nil { + if !models.IsErrUserNotExist(err) { + log.Error("GetUserById: %v", err) + } + return nil + } + + log.Trace("Session Authorization: Logged in user %-v", user) + return user +} diff --git a/services/auth/sspi_windows.go b/services/auth/sspi_windows.go new file mode 100644 index 0000000000..d1289a7617 --- /dev/null +++ b/services/auth/sspi_windows.go @@ -0,0 +1,246 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "errors" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web/middleware" + + gouuid "github.com/google/uuid" + "github.com/quasoft/websspi" + "github.com/unrolled/render" +) + +const ( + tplSignIn base.TplName = "user/auth/signin" +) + +var ( + // sspiAuth is a global instance of the websspi authentication package, + // which is used to avoid acquiring the server credential handle on + // every request + sspiAuth *websspi.Authenticator + + // Ensure the struct implements the interface. + _ Auth = &SSPI{} +) + +// SSPI implements the SingleSignOn interface and authenticates requests +// via the built-in SSPI module in Windows for SPNEGO authentication. +// On successful authentication returns a valid user object. +// Returns nil if authentication fails. +type SSPI struct { + rnd *render.Render +} + +// Init creates a new global websspi.Authenticator object +func (s *SSPI) Init() error { + config := websspi.NewConfig() + var err error + sspiAuth, err = websspi.New(config) + if err != nil { + return err + } + s.rnd = render.New(render.Options{ + Extensions: []string{".tmpl"}, + Directory: "templates", + Funcs: templates.NewFuncMap(), + Asset: templates.GetAsset, + AssetNames: templates.GetAssetNames, + IsDevelopment: !setting.IsProd(), + }) + return nil +} + +// Name represents the name of auth method +func (s *SSPI) Name() string { + return "sspi" +} + +// Free releases resources used by the global websspi.Authenticator object +func (s *SSPI) Free() error { + return sspiAuth.Free() +} + +// Verify uses SSPI (Windows implementation of SPNEGO) to authenticate the request. +// If authentication is successful, returs the corresponding user object. +// If negotiation should continue or authentication fails, immediately returns a 401 HTTP +// response code, as required by the SPNEGO protocol. +func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User { + if !s.shouldAuthenticate(req) { + return nil + } + + cfg, err := s.getConfig() + if err != nil { + log.Error("could not get SSPI config: %v", err) + return nil + } + + log.Trace("SSPI Authorization: Attempting to authenticate") + userInfo, outToken, err := sspiAuth.Authenticate(req, w) + if err != nil { + log.Warn("Authentication failed with error: %v\n", err) + sspiAuth.AppendAuthenticateHeader(w, outToken) + + // Include the user login page in the 401 response to allow the user + // to login with another authentication method if SSPI authentication + // fails + store.GetData()["Flash"] = map[string]string{ + "ErrorMsg": err.Error(), + } + store.GetData()["EnableOpenIDSignIn"] = setting.Service.EnableOpenIDSignIn + store.GetData()["EnableSSPI"] = true + + err := s.rnd.HTML(w, 401, string(tplSignIn), templates.BaseVars().Merge(store.GetData())) + if err != nil { + log.Error("%v", err) + } + + return nil + } + if outToken != "" { + sspiAuth.AppendAuthenticateHeader(w, outToken) + } + + username := sanitizeUsername(userInfo.Username, cfg) + if len(username) == 0 { + return nil + } + log.Info("Authenticated as %s\n", username) + + user, err := models.GetUserByName(username) + if err != nil { + if !models.IsErrUserNotExist(err) { + log.Error("GetUserByName: %v", err) + return nil + } + if !cfg.AutoCreateUsers { + log.Error("User '%s' not found", username) + return nil + } + user, err = s.newUser(username, cfg) + if err != nil { + log.Error("CreateUser: %v", err) + return nil + } + } + + // Make sure requests to API paths and PWA resources do not create a new session + if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) { + handleSignIn(w, req, sess, user) + } + + log.Trace("SSPI Authorization: Logged in user %-v", user) + return user +} + +// getConfig retrieves the SSPI configuration from login sources +func (s *SSPI) getConfig() (*models.SSPIConfig, error) { + sources, err := models.ActiveLoginSources(models.LoginSSPI) + if err != nil { + return nil, err + } + if len(sources) == 0 { + return nil, errors.New("no active login sources of type SSPI found") + } + if len(sources) > 1 { + return nil, errors.New("more than one active login source of type SSPI found") + } + return sources[0].SSPI(), nil +} + +func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) { + shouldAuth = false + path := strings.TrimSuffix(req.URL.Path, "/") + if path == "/user/login" { + if req.FormValue("user_name") != "" && req.FormValue("password") != "" { + shouldAuth = false + } else if req.FormValue("auth_with_sspi") == "1" { + shouldAuth = true + } + } else if middleware.IsAPIPath(req) || isAttachmentDownload(req) { + shouldAuth = true + } + return +} + +// newUser creates a new user object for the purpose of automatic registration +// and populates its name and email with the information present in request headers. +func (s *SSPI) newUser(username string, cfg *models.SSPIConfig) (*models.User, error) { + email := gouuid.New().String() + "@localhost.localdomain" + user := &models.User{ + Name: username, + Email: email, + KeepEmailPrivate: true, + Passwd: gouuid.New().String(), + IsActive: cfg.AutoActivateUsers, + Language: cfg.DefaultLanguage, + UseCustomAvatar: true, + Avatar: models.DefaultAvatarLink(), + EmailNotificationsPreference: models.EmailNotificationsDisabled, + } + if err := models.CreateUser(user); err != nil { + return nil, err + } + return user, nil +} + +// stripDomainNames removes NETBIOS domain name and separator from down-level logon names +// (eg. "DOMAIN\user" becomes "user"), and removes the UPN suffix (domain name) and separator +// from UPNs (eg. "user@domain.local" becomes "user") +func stripDomainNames(username string) string { + if strings.Contains(username, "\\") { + parts := strings.SplitN(username, "\\", 2) + if len(parts) > 1 { + username = parts[1] + } + } else if strings.Contains(username, "@") { + parts := strings.Split(username, "@") + if len(parts) > 1 { + username = parts[0] + } + } + return username +} + +func replaceSeparators(username string, cfg *models.SSPIConfig) string { + newSep := cfg.SeparatorReplacement + username = strings.ReplaceAll(username, "\\", newSep) + username = strings.ReplaceAll(username, "/", newSep) + username = strings.ReplaceAll(username, "@", newSep) + return username +} + +func sanitizeUsername(username string, cfg *models.SSPIConfig) string { + if len(username) == 0 { + return "" + } + if cfg.StripDomainNames { + username = stripDomainNames(username) + } + // Replace separators even if we have already stripped the domain name part, + // as the username can contain several separators: eg. "MICROSOFT\useremail@live.com" + username = replaceSeparators(username, cfg) + return username +} + +// specialInit registers the SSPI auth method as the last method in the list. +// The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation +// fails (or if negotiation should continue), which would prevent other authentication methods +// to execute at all. +func specialInit() { + if models.IsSSPIEnabled() { + Register(&SSPI{}) + } +} |