5bb8d1924d
Closes https://github.com/go-gitea/gitea/issues/5512 This PR adds basic SAML support - Adds SAML 2.0 as an auth source - Adds SAML configuration documentation - Adds integration test: - Use bare-bones SAML IdP to test protocol flow and test account is linked successfully (only runs on Postgres by default) - Adds documentation for configuring and running SAML integration test locally Future PRs: - Support group mapping - Support auto-registration (account linking) Co-Authored-By: @jackHay22 --------- Co-authored-by: jackHay22 <jack@allspice.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: KN4CK3R <admin@oldschoolhack.me> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Jason Song <i@wolfogre.com> Co-authored-by: morphelinho <morphelinho@users.noreply.github.com> Co-authored-by: Zettat123 <zettat123@gmail.com> Co-authored-by: Yarden Shoham <git@yardenshoham.com> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: silverwind <me@silverwind.io>
311 lines
12 KiB
Go
311 lines
12 KiB
Go
// Copyright 2017 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"code.gitea.io/gitea/models/auth"
|
|
user_model "code.gitea.io/gitea/models/user"
|
|
"code.gitea.io/gitea/modules/base"
|
|
"code.gitea.io/gitea/modules/context"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/util"
|
|
"code.gitea.io/gitea/modules/web"
|
|
auth_service "code.gitea.io/gitea/services/auth"
|
|
"code.gitea.io/gitea/services/auth/source/oauth2"
|
|
"code.gitea.io/gitea/services/externalaccount"
|
|
"code.gitea.io/gitea/services/forms"
|
|
|
|
"github.com/markbates/goth"
|
|
)
|
|
|
|
var tplLinkAccount base.TplName = "user/auth/link_account"
|
|
|
|
// LinkAccount shows the page where the user can decide to login or create a new account
|
|
func LinkAccount(ctx *context.Context) {
|
|
ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
|
|
ctx.Data["Title"] = ctx.Tr("link_account")
|
|
ctx.Data["LinkAccountMode"] = true
|
|
ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
|
|
ctx.Data["Captcha"] = context.GetImageCaptcha()
|
|
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
|
|
ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
|
|
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
|
|
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
|
|
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
|
|
ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
|
|
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
|
|
ctx.Data["AllowOnlyInternalRegistration"] = setting.Service.AllowOnlyInternalRegistration
|
|
ctx.Data["ShowRegistrationButton"] = false
|
|
|
|
// use this to set the right link into the signIn and signUp templates in the link_account template
|
|
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
|
|
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
|
|
|
|
externalLinkUser := ctx.Session.Get("linkAccountUser")
|
|
if externalLinkUser == nil {
|
|
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
|
|
return
|
|
}
|
|
|
|
gu := externalLinkUser.(auth.LinkAccountUser).GothUser
|
|
uname, err := getUserName(&gu)
|
|
if err != nil {
|
|
ctx.ServerError("UserSignIn", err)
|
|
return
|
|
}
|
|
email := gu.Email
|
|
ctx.Data["user_name"] = uname
|
|
ctx.Data["email"] = email
|
|
|
|
if len(email) != 0 {
|
|
u, err := user_model.GetUserByEmail(ctx, email)
|
|
if err != nil && !user_model.IsErrUserNotExist(err) {
|
|
ctx.ServerError("UserSignIn", err)
|
|
return
|
|
}
|
|
if u != nil {
|
|
ctx.Data["user_exists"] = true
|
|
}
|
|
} else if len(uname) != 0 {
|
|
u, err := user_model.GetUserByName(ctx, uname)
|
|
if err != nil && !user_model.IsErrUserNotExist(err) {
|
|
ctx.ServerError("UserSignIn", err)
|
|
return
|
|
}
|
|
if u != nil {
|
|
ctx.Data["user_exists"] = true
|
|
}
|
|
}
|
|
|
|
ctx.HTML(http.StatusOK, tplLinkAccount)
|
|
}
|
|
|
|
func handleSignInError(ctx *context.Context, userName string, ptrForm any, tmpl base.TplName, invoker string, err error) {
|
|
if errors.Is(err, util.ErrNotExist) {
|
|
ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
|
|
} else if errors.Is(err, util.ErrInvalidArgument) {
|
|
ctx.Data["user_exists"] = true
|
|
ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tmpl, ptrForm)
|
|
} else if user_model.IsErrUserProhibitLogin(err) {
|
|
ctx.Data["user_exists"] = true
|
|
log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err)
|
|
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
|
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
|
} else if user_model.IsErrUserInactive(err) {
|
|
ctx.Data["user_exists"] = true
|
|
if setting.Service.RegisterEmailConfirm {
|
|
ctx.Data["Title"] = ctx.Tr("auth.active_your_account")
|
|
ctx.HTML(http.StatusOK, TplActivate)
|
|
} else {
|
|
log.Info("Failed authentication attempt for %s from %s: %v", userName, ctx.RemoteAddr(), err)
|
|
ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
|
|
ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
|
|
}
|
|
} else {
|
|
ctx.ServerError(invoker, err)
|
|
}
|
|
}
|
|
|
|
// LinkAccountPostSignIn handle the coupling of external account with another account using signIn
|
|
func LinkAccountPostSignIn(ctx *context.Context) {
|
|
signInForm := web.GetForm(ctx).(*forms.SignInForm)
|
|
ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
|
|
ctx.Data["Title"] = ctx.Tr("link_account")
|
|
ctx.Data["LinkAccountMode"] = true
|
|
ctx.Data["LinkAccountModeSignIn"] = true
|
|
ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
|
|
ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
|
|
ctx.Data["Captcha"] = context.GetImageCaptcha()
|
|
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
|
|
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
|
|
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
|
|
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
|
|
ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
|
|
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
|
|
ctx.Data["ShowRegistrationButton"] = false
|
|
|
|
// use this to set the right link into the signIn and signUp templates in the link_account template
|
|
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
|
|
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
|
|
|
|
externalLinkUserInterface := ctx.Session.Get("linkAccountUser")
|
|
if externalLinkUserInterface == nil {
|
|
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
|
|
return
|
|
}
|
|
|
|
externalLinkUser := externalLinkUserInterface.(auth.LinkAccountUser)
|
|
|
|
if ctx.HasError() {
|
|
ctx.HTML(http.StatusOK, tplLinkAccount)
|
|
return
|
|
}
|
|
|
|
u, _, err := auth_service.UserSignIn(ctx, signInForm.UserName, signInForm.Password)
|
|
if err != nil {
|
|
handleSignInError(ctx, signInForm.UserName, &signInForm, tplLinkAccount, "UserLinkAccount", err)
|
|
return
|
|
}
|
|
|
|
linkAccount(ctx, u, externalLinkUser.GothUser, signInForm.Remember, externalLinkUser.Type)
|
|
}
|
|
|
|
func linkAccount(ctx *context.Context, u *user_model.User, gothUser goth.User, remember bool, authType auth.Type) {
|
|
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
|
|
|
|
// If this user is enrolled in 2FA, we can't sign the user in just yet.
|
|
// Instead, redirect them to the 2FA authentication page.
|
|
// We deliberately ignore the skip local 2fa setting here because we are linking to a previous user here
|
|
_, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
|
if err != nil {
|
|
if !auth.IsErrTwoFactorNotEnrolled(err) {
|
|
ctx.ServerError("UserLinkAccount", err)
|
|
return
|
|
}
|
|
|
|
err = externalaccount.LinkAccountToUser(ctx, u, gothUser, authType)
|
|
if err != nil {
|
|
ctx.ServerError("UserLinkAccount", err)
|
|
return
|
|
}
|
|
|
|
handleSignIn(ctx, u, remember)
|
|
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": remember,
|
|
"linkAccount": true,
|
|
}); err != nil {
|
|
ctx.ServerError("RegenerateSession", 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")
|
|
}
|
|
|
|
// LinkAccountPostRegister handle the creation of a new account for an external account using signUp
|
|
func LinkAccountPostRegister(ctx *context.Context) {
|
|
form := web.GetForm(ctx).(*forms.RegisterForm)
|
|
// TODO Make insecure passwords optional for local accounts also,
|
|
// once email-based Second-Factor Auth is available
|
|
ctx.Data["DisablePassword"] = !setting.Service.RequireExternalRegistrationPassword || setting.Service.AllowOnlyExternalRegistration
|
|
ctx.Data["Title"] = ctx.Tr("link_account")
|
|
ctx.Data["LinkAccountMode"] = true
|
|
ctx.Data["LinkAccountModeRegister"] = true
|
|
ctx.Data["EnableCaptcha"] = setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha
|
|
ctx.Data["RecaptchaURL"] = setting.Service.RecaptchaURL
|
|
ctx.Data["Captcha"] = context.GetImageCaptcha()
|
|
ctx.Data["CaptchaType"] = setting.Service.CaptchaType
|
|
ctx.Data["RecaptchaSitekey"] = setting.Service.RecaptchaSitekey
|
|
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
|
|
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
|
|
ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
|
|
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
|
|
ctx.Data["ShowRegistrationButton"] = false
|
|
|
|
// use this to set the right link into the signIn and signUp templates in the link_account template
|
|
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
|
|
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
|
|
|
|
externalLinkUser := ctx.Session.Get("linkAccountUser")
|
|
if externalLinkUser == nil {
|
|
ctx.ServerError("UserSignUp", errors.New("not in LinkAccount session"))
|
|
return
|
|
}
|
|
linkUser, ok := externalLinkUser.(auth.LinkAccountUser)
|
|
if !ok {
|
|
ctx.ServerError("UserSignUp", fmt.Errorf("session linkAccountUser type is %t but not goth.User", externalLinkUser))
|
|
return
|
|
}
|
|
|
|
if ctx.HasError() {
|
|
ctx.HTML(http.StatusOK, tplLinkAccount)
|
|
return
|
|
}
|
|
|
|
if setting.Service.DisableRegistration || setting.Service.AllowOnlyInternalRegistration {
|
|
ctx.Error(http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
|
|
context.VerifyCaptcha(ctx, tplLinkAccount, form)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
}
|
|
|
|
if !form.IsEmailDomainAllowed() {
|
|
ctx.RenderWithErr(ctx.Tr("auth.email_domain_blacklisted"), tplLinkAccount, &form)
|
|
return
|
|
}
|
|
|
|
if setting.Service.AllowOnlyExternalRegistration || !setting.Service.RequireExternalRegistrationPassword {
|
|
// In user_model.User an empty password is classed as not set, so we set form.Password to empty.
|
|
// Eventually the database should be changed to indicate "Second Factor"-enabled accounts
|
|
// (accounts that do not introduce the security vulnerabilities of a password).
|
|
// If a user decides to circumvent second-factor security, and purposefully create a password,
|
|
// they can still do so using the "Recover Account" option.
|
|
form.Password = ""
|
|
} else {
|
|
if (len(strings.TrimSpace(form.Password)) > 0 || len(strings.TrimSpace(form.Retype)) > 0) && form.Password != form.Retype {
|
|
ctx.Data["Err_Password"] = true
|
|
ctx.RenderWithErr(ctx.Tr("form.password_not_match"), tplLinkAccount, &form)
|
|
return
|
|
}
|
|
if len(strings.TrimSpace(form.Password)) > 0 && len(form.Password) < setting.MinPasswordLength {
|
|
ctx.Data["Err_Password"] = true
|
|
ctx.RenderWithErr(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplLinkAccount, &form)
|
|
return
|
|
}
|
|
}
|
|
|
|
authSource, err := auth.GetActiveAuthSourceByName(ctx, linkUser.GothUser.Provider, linkUser.Type)
|
|
if err != nil {
|
|
ctx.ServerError("CreateUser", err)
|
|
return
|
|
}
|
|
|
|
u := &user_model.User{
|
|
Name: form.UserName,
|
|
Email: form.Email,
|
|
Passwd: form.Password,
|
|
LoginType: authSource.Type,
|
|
LoginSource: authSource.ID,
|
|
LoginName: linkUser.GothUser.UserID,
|
|
}
|
|
|
|
if !createAndHandleCreatedUser(ctx, tplLinkAccount, form, u, nil, &linkUser.GothUser, false, linkUser.Type) {
|
|
// error already handled
|
|
return
|
|
}
|
|
|
|
if linkUser.Type == auth.OAuth2 {
|
|
source := authSource.Cfg.(*oauth2.Source)
|
|
if err := syncGroupsToTeams(ctx, source, &linkUser.GothUser, u); err != nil {
|
|
ctx.ServerError("SyncGroupsToTeams", err)
|
|
return
|
|
}
|
|
}
|
|
// TODO we will support some form of group mapping for SAML
|
|
|
|
handleSignIn(ctx, u, false)
|
|
}
|