| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- // Copyright 2019 The Gitea Authors. All rights reserved.
- // SPDX-License-Identifier: MIT
-
- package auth
-
- import (
- "encoding/gob"
- "errors"
- "fmt"
- "html"
- "io"
- "net/http"
- "sort"
- "strings"
-
- "code.gitea.io/gitea/models/auth"
- user_model "code.gitea.io/gitea/models/user"
- auth_module "code.gitea.io/gitea/modules/auth"
- "code.gitea.io/gitea/modules/container"
- "code.gitea.io/gitea/modules/log"
- "code.gitea.io/gitea/modules/optional"
- "code.gitea.io/gitea/modules/session"
- "code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/modules/web/middleware"
- source_service "code.gitea.io/gitea/services/auth/source"
- "code.gitea.io/gitea/services/auth/source/oauth2"
- "code.gitea.io/gitea/services/context"
- "code.gitea.io/gitea/services/externalaccount"
- user_service "code.gitea.io/gitea/services/user"
-
- "github.com/markbates/goth"
- "github.com/markbates/goth/gothic"
- go_oauth2 "golang.org/x/oauth2"
- )
-
- // SignInOAuth handles the OAuth2 login buttons
- func SignInOAuth(ctx *context.Context) {
- authName := ctx.PathParam("provider")
- authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
- if err != nil {
- ctx.ServerError("SignIn", err)
- return
- }
-
- redirectTo := ctx.FormString("redirect_to")
- if len(redirectTo) > 0 {
- middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
- }
-
- // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
- user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
- if err == nil && user != nil {
- // we got the user without going through the whole OAuth2 authentication flow again
- handleOAuth2SignIn(ctx, authSource, user, gothUser)
- return
- }
-
- if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); err != nil {
- if strings.Contains(err.Error(), "no provider for ") {
- if err = oauth2.ResetOAuth2(ctx); err != nil {
- ctx.ServerError("SignIn", err)
- return
- }
- if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); err != nil {
- ctx.ServerError("SignIn", err)
- }
- return
- }
- ctx.ServerError("SignIn", err)
- }
- // redirect is done in oauth2.Auth
- }
-
- // SignInOAuthCallback handles the callback from the given provider
- func SignInOAuthCallback(ctx *context.Context) {
- if ctx.Req.FormValue("error") != "" {
- var errorKeyValues []string
- for k, vv := range ctx.Req.Form {
- for _, v := range vv {
- errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
- }
- }
- sort.Strings(errorKeyValues)
- ctx.Flash.Error(strings.Join(errorKeyValues, "<br>"), true)
- }
-
- // first look if the provider is still active
- authName := ctx.PathParam("provider")
- authSource, err := auth.GetActiveOAuth2SourceByAuthName(ctx, authName)
- if err != nil {
- ctx.ServerError("SignIn", err)
- return
- }
-
- if authSource == nil {
- ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
- return
- }
-
- u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
- if err != nil {
- if user_model.IsErrUserProhibitLogin(err) {
- uplerr := err.(user_model.ErrUserProhibitLogin)
- log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
- ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
- ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
- return
- }
- if callbackErr, ok := err.(errCallback); ok {
- log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
- switch callbackErr.Code {
- case "access_denied":
- ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
- case "temporarily_unavailable":
- ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
- default:
- ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.general", callbackErr.Description))
- }
- ctx.Redirect(setting.AppSubURL + "/user/login")
- return
- }
- if err, ok := err.(*go_oauth2.RetrieveError); ok {
- ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true)
- ctx.Redirect(setting.AppSubURL + "/user/login")
- return
- }
- ctx.ServerError("UserSignIn", err)
- return
- }
-
- if u == nil {
- if ctx.Doer != nil {
- // attach user to the current signed-in user
- err = externalaccount.LinkAccountToUser(ctx, authSource.ID, ctx.Doer, gothUser)
- if err != nil {
- ctx.ServerError("UserLinkAccount", err)
- return
- }
-
- ctx.Redirect(setting.AppSubURL + "/user/settings/security")
- return
- } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration {
- // create new user with details from oauth2 provider
- var missingFields []string
- if gothUser.UserID == "" {
- missingFields = append(missingFields, "sub")
- }
- if gothUser.Email == "" {
- missingFields = append(missingFields, "email")
- }
- uname, err := extractUserNameFromOAuth2(&gothUser)
- if err != nil {
- ctx.ServerError("UserSignIn", err)
- return
- }
- if uname == "" {
- switch setting.OAuth2Client.Username {
- case setting.OAuth2UsernameNickname:
- missingFields = append(missingFields, "nickname")
- case setting.OAuth2UsernamePreferredUsername:
- missingFields = append(missingFields, "preferred_username")
- } // else: "UserID" and "Email" have been handled above separately
- }
- if len(missingFields) > 0 {
- log.Error(`OAuth2 auto registration (ENABLE_AUTO_REGISTRATION) is enabled but OAuth2 provider %q doesn't return required fields: %s. `+
- `Suggest to: disable auto registration, or make OPENID_CONNECT_SCOPES (for OpenIDConnect) / Authentication Source Scopes (for Admin panel) to request all required fields, and the fields shouldn't be empty.`,
- authSource.Name, strings.Join(missingFields, ","))
- // The RawData is the only way to pass the missing fields to the another page at the moment, other ways all have various problems:
- // by session or cookie: difficult to clean or reset; by URL: could be injected with uncontrollable content; by ctx.Flash: the link_account page is a mess ...
- // Since the RawData is for the provider's data, so we need to use our own prefix here to avoid conflict.
- if gothUser.RawData == nil {
- gothUser.RawData = make(map[string]any)
- }
- gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields
- showLinkingLogin(ctx, authSource.ID, gothUser)
- return
- }
- u = &user_model.User{
- Name: uname,
- Email: gothUser.Email,
- LoginType: auth.OAuth2,
- LoginSource: authSource.ID,
- LoginName: gothUser.UserID,
- }
-
- overwriteDefault := &user_model.CreateUserOverwriteOptions{
- IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm),
- }
-
- source := authSource.Cfg.(*oauth2.Source)
-
- isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser)
- u.IsAdmin = isAdmin.ValueOrDefault(user_service.UpdateOptionField[bool]{FieldValue: false}).FieldValue
- u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
-
- linkAccountData := &LinkAccountData{authSource.ID, gothUser}
- if setting.OAuth2Client.AccountLinking == setting.OAuth2AccountLinkingDisabled {
- linkAccountData = nil
- }
- if !createAndHandleCreatedUser(ctx, "", nil, u, overwriteDefault, linkAccountData) {
- // error already handled
- return
- }
-
- if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
- ctx.ServerError("SyncGroupsToTeams", err)
- return
- }
- } else {
- // no existing user is found, request attach or new account
- showLinkingLogin(ctx, authSource.ID, gothUser)
- return
- }
- }
-
- handleOAuth2SignIn(ctx, authSource, u, gothUser)
- }
-
- func claimValueToStringSet(claimValue any) container.Set[string] {
- var groups []string
-
- switch rawGroup := claimValue.(type) {
- case []string:
- groups = rawGroup
- case []any:
- for _, group := range rawGroup {
- groups = append(groups, fmt.Sprintf("%s", group))
- }
- default:
- str := fmt.Sprintf("%s", rawGroup)
- groups = strings.Split(str, ",")
- }
- return container.SetOf(groups...)
- }
-
- func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error {
- if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
- groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
- if err != nil {
- return err
- }
-
- groups := getClaimedGroups(source, gothUser)
-
- if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
- return err
- }
- }
-
- return nil
- }
-
- func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
- groupClaims, has := gothUser.RawData[source.GroupClaimName]
- if !has {
- return nil
- }
-
- return claimValueToStringSet(groupClaims)
- }
-
- func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin optional.Option[user_service.UpdateOptionField[bool]], isRestricted optional.Option[bool]) {
- groups := getClaimedGroups(source, gothUser)
-
- if source.AdminGroup != "" {
- isAdmin = user_service.UpdateOptionFieldFromSync(groups.Contains(source.AdminGroup))
- }
- if source.RestrictedGroup != "" {
- isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
- }
-
- return isAdmin, isRestricted
- }
-
- type LinkAccountData struct {
- AuthSourceID int64
- GothUser goth.User
- }
-
- func oauth2GetLinkAccountData(ctx *context.Context) *LinkAccountData {
- gob.Register(LinkAccountData{})
- v, ok := ctx.Session.Get("linkAccountData").(LinkAccountData)
- if !ok {
- return nil
- }
- return &v
- }
-
- func Oauth2SetLinkAccountData(ctx *context.Context, linkAccountData LinkAccountData) error {
- gob.Register(LinkAccountData{})
- return updateSession(ctx, nil, map[string]any{
- "linkAccountData": linkAccountData,
- })
- }
-
- func showLinkingLogin(ctx *context.Context, authSourceID int64, gothUser goth.User) {
- if err := Oauth2SetLinkAccountData(ctx, LinkAccountData{authSourceID, gothUser}); err != nil {
- ctx.ServerError("Oauth2SetLinkAccountData", err)
- return
- }
- ctx.Redirect(setting.AppSubURL + "/user/link_account")
- }
-
- func oauth2UpdateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
- if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
- resp, err := http.Get(url)
- if err == nil {
- defer func() {
- _ = resp.Body.Close()
- }()
- }
- // ignore any error
- if err == nil && resp.StatusCode == http.StatusOK {
- data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
- if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize {
- _ = user_service.UploadAvatar(ctx, u, data)
- }
- }
- }
- }
-
- func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_model.User, gothUser goth.User) {
- oauth2SignInSync(ctx, authSource.ID, u, gothUser)
- if ctx.Written() {
- return
- }
-
- needs2FA := false
- if !authSource.TwoFactorShouldSkip() {
- _, err := auth.GetTwoFactorByUID(ctx, u.ID)
- if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
- ctx.ServerError("UserSignIn", err)
- return
- }
- needs2FA = err == nil
- }
-
- oauth2Source := authSource.Cfg.(*oauth2.Source)
- groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
- if err != nil {
- ctx.ServerError("UnmarshalGroupTeamMapping", err)
- return
- }
-
- groups := getClaimedGroups(oauth2Source, &gothUser)
-
- opts := &user_service.UpdateOptions{}
-
- // Reactivate user if they are deactivated
- if !u.IsActive {
- opts.IsActive = optional.Some(true)
- }
-
- // Update GroupClaims
- opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
-
- if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
- if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
- ctx.ServerError("SyncGroupsToTeams", err)
- return
- }
- }
-
- if err := externalaccount.EnsureLinkExternalToUser(ctx, authSource.ID, u, gothUser); err != nil {
- ctx.ServerError("EnsureLinkExternalToUser", err)
- return
- }
-
- // If this user is enrolled in 2FA and this source doesn't override it,
- // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
- if !needs2FA {
- // Register last login
- opts.SetLastLogin = true
-
- if err := user_service.UpdateUser(ctx, u, opts); err != nil {
- ctx.ServerError("UpdateUser", err)
- return
- }
- userHasTwoFactorAuth, err := auth.HasTwoFactorOrWebAuthn(ctx, u.ID)
- if err != nil {
- ctx.ServerError("UpdateUser", err)
- return
- }
-
- if err := updateSession(ctx, nil, map[string]any{
- session.KeyUID: u.ID,
- session.KeyUname: u.Name,
- session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
- }); err != nil {
- ctx.ServerError("updateSession", err)
- return
- }
-
- // force to generate a new CSRF token
- ctx.Csrf.PrepareForSessionUser(ctx)
-
- if err := resetLocale(ctx, u); err != nil {
- ctx.ServerError("resetLocale", err)
- return
- }
-
- if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
- middleware.DeleteRedirectToCookie(ctx.Resp)
- ctx.RedirectToCurrentSite(redirectTo)
- return
- }
-
- ctx.Redirect(setting.AppSubURL + "/")
- return
- }
-
- if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() {
- if err := user_service.UpdateUser(ctx, u, opts); err != nil {
- ctx.ServerError("UpdateUser", err)
- return
- }
- }
-
- if err := updateSession(ctx, nil, map[string]any{
- // User needs to use 2FA, save data and redirect to 2FA page.
- "twofaUid": u.ID,
- "twofaRemember": false,
- }); err != nil {
- ctx.ServerError("updateSession", err)
- return
- }
-
- // If WebAuthn is enrolled -> Redirect to WebAuthn instead
- regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
- if err == nil && len(regs) > 0 {
- ctx.Redirect(setting.AppSubURL + "/user/webauthn")
- return
- }
-
- ctx.Redirect(setting.AppSubURL + "/user/two_factor")
- }
-
- // OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
- // login the user
- func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
- oauth2Source := authSource.Cfg.(*oauth2.Source)
-
- // Make sure that the response is not an error response.
- errorName := request.FormValue("error")
-
- if len(errorName) > 0 {
- errorDescription := request.FormValue("error_description")
-
- // Delete the goth session
- err := gothic.Logout(response, request)
- if err != nil {
- return nil, goth.User{}, err
- }
-
- return nil, goth.User{}, errCallback{
- Code: errorName,
- Description: errorDescription,
- }
- }
-
- // Proceed to authenticate through goth.
- gothUser, err := oauth2Source.Callback(request, response)
- if err != nil {
- if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
- err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
- log.Error("oauth2Source.Callback failed: %v", err)
- } else {
- err = errCallback{Code: "internal", Description: err.Error()}
- }
- return nil, goth.User{}, err
- }
-
- if oauth2Source.RequiredClaimName != "" {
- claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
- if !has {
- return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
- }
-
- if oauth2Source.RequiredClaimValue != "" {
- groups := claimValueToStringSet(claimInterface)
-
- if !groups.Contains(oauth2Source.RequiredClaimValue) {
- return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
- }
- }
- }
-
- user := &user_model.User{
- LoginName: gothUser.UserID,
- LoginType: auth.OAuth2,
- LoginSource: authSource.ID,
- }
-
- hasUser, err := user_model.GetUser(ctx, user)
- if err != nil {
- return nil, goth.User{}, err
- }
-
- if hasUser {
- return user, gothUser, nil
- }
-
- // search in external linked users
- externalLoginUser := &user_model.ExternalLoginUser{
- ExternalID: gothUser.UserID,
- LoginSourceID: authSource.ID,
- }
- hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
- if err != nil {
- return nil, goth.User{}, err
- }
- if hasUser {
- user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
- return user, gothUser, err
- }
-
- // no user found to login
- return nil, gothUser, nil
- }
|