mirror of https://github.com/go-gitea/gitea.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
496 lines
16 KiB
496 lines
16 KiB
// Copyright 2019 The Gitea Authors. All rights reserved. |
|
// SPDX-License-Identifier: MIT |
|
|
|
package auth |
|
|
|
import ( |
|
"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/templates" |
|
"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) { |
|
provider := ctx.PathParam("provider") |
|
|
|
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) |
|
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) { |
|
provider := ctx.PathParam("provider") |
|
|
|
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 |
|
authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) |
|
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, 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, gothUser) |
|
return |
|
} |
|
u = &user_model.User{ |
|
Name: uname, |
|
FullName: gothUser.Name, |
|
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) |
|
|
|
if !createAndHandleCreatedUser(ctx, templates.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { |
|
// 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, 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 |
|
} |
|
|
|
func showLinkingLogin(ctx *context.Context, gothUser goth.User) { |
|
if err := updateSession(ctx, nil, map[string]any{ |
|
"linkAccountGothUser": gothUser, |
|
}); err != nil { |
|
ctx.ServerError("updateSession", err) |
|
return |
|
} |
|
ctx.Redirect(setting.AppSubURL + "/user/link_account") |
|
} |
|
|
|
func updateAvatarIfNeed(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, source *auth.Source, u *user_model.User, gothUser goth.User) { |
|
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) |
|
|
|
needs2FA := false |
|
if !source.TwoFactorShouldSkip() { |
|
_, err := auth.GetTwoFactorByUID(ctx, u.ID) |
|
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { |
|
ctx.ServerError("UserSignIn", err) |
|
return |
|
} |
|
needs2FA = err == nil |
|
} |
|
|
|
oauth2Source := source.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, 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 |
|
}
|
|
|